From 7d1770ea83d59a240f6258af48f73def19199eb4 Mon Sep 17 00:00:00 2001 From: Dzmitry Luzko Date: Wed, 9 Feb 2022 01:02:36 +0300 Subject: [PATCH 1/5] Add DB --- pom.xml | 13 +++++- .../bootjava/RestaurantVotingApplication.java | 11 ++++- .../ru/javaops/bootjava/config/AppConfig.java | 26 +++++++++++ .../java/ru/javaops/bootjava/model/Role.java | 6 +++ .../java/ru/javaops/bootjava/model/User.java | 44 +++++++++++++++++++ .../bootjava/repository/UserRepository.java | 7 +++ src/main/resources/application.properties | 0 src/main/resources/application.yaml | 30 +++++++++++++ src/main/resources/data.sql | 8 ++++ 9 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 src/main/java/ru/javaops/bootjava/config/AppConfig.java create mode 100644 src/main/java/ru/javaops/bootjava/model/Role.java create mode 100644 src/main/java/ru/javaops/bootjava/model/User.java create mode 100644 src/main/java/ru/javaops/bootjava/repository/UserRepository.java delete mode 100644 src/main/resources/application.properties create mode 100644 src/main/resources/application.yaml create mode 100644 src/main/resources/data.sql diff --git a/pom.xml b/pom.xml index ca66a72..0fe4458 100644 --- a/pom.xml +++ b/pom.xml @@ -28,11 +28,14 @@ org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-validation + com.h2database h2 - runtime org.projectlombok @@ -53,6 +56,14 @@ org.springframework.boot spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + diff --git a/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java b/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java index 3326420..d3b1792 100644 --- a/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java +++ b/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java @@ -1,14 +1,23 @@ package ru.javaops.bootjava; import lombok.AllArgsConstructor; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import ru.javaops.bootjava.repository.UserRepository; @SpringBootApplication @AllArgsConstructor -public class RestaurantVotingApplication { +public class RestaurantVotingApplication implements ApplicationRunner { + private final UserRepository userRepository; public static void main(String[] args) { SpringApplication.run(RestaurantVotingApplication.class, args); } + + @Override + public void run(ApplicationArguments args) { + System.out.println(userRepository.findAll()); + } } diff --git a/src/main/java/ru/javaops/bootjava/config/AppConfig.java b/src/main/java/ru/javaops/bootjava/config/AppConfig.java new file mode 100644 index 0000000..19dbc45 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/config/AppConfig.java @@ -0,0 +1,26 @@ +package ru.javaops.bootjava.config; + +import lombok.extern.slf4j.Slf4j; +import org.h2.tools.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.sql.SQLException; + +@Configuration +@Slf4j +public class AppConfig { + +/* + @Bean(initMethod = "start", destroyMethod = "stop") + public Server h2WebServer() throws SQLException { + return Server.createWebServer("-web", "-webAllowOthers", "-webPort", "8082"); + } +*/ + + @Bean(initMethod = "start", destroyMethod = "stop") + public Server h2Server() throws SQLException { + log.info("Start H2 TCP server"); + return Server.createTcpServer("-tcp", "-tcpAllowOthers", "-tcpPort", "9092"); + } +} diff --git a/src/main/java/ru/javaops/bootjava/model/Role.java b/src/main/java/ru/javaops/bootjava/model/Role.java new file mode 100644 index 0000000..cd76006 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/model/Role.java @@ -0,0 +1,6 @@ +package ru.javaops.bootjava.model; + +public enum Role { + ROLE_USER, + ROLE_ADMIN +} diff --git a/src/main/java/ru/javaops/bootjava/model/User.java b/src/main/java/ru/javaops/bootjava/model/User.java new file mode 100644 index 0000000..538a112 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/model/User.java @@ -0,0 +1,44 @@ +package ru.javaops.bootjava.model; + +import lombok.*; +import org.springframework.data.jpa.domain.AbstractPersistable; + +import javax.persistence.*; +import javax.validation.constraints.Email; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.Size; +import java.util.Set; + +@Entity +@Table(name = "users") +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@ToString(callSuper = true, exclude = {"password"}) +public class User extends AbstractPersistable { + + @Column(name = "email", nullable = false, unique = true) + @Email + @NotEmpty + @Size(max = 128) + private String email; + + @Column(name = "first_name") + @Size(max = 128) + private String firstName; + + @Column(name = "last_name") + @Size(max = 128) + private String lastName; + + @Column(name = "password") + @Size(max = 256) + private String password; + + @Enumerated(EnumType.STRING) + @CollectionTable(name = "user_role", joinColumns = @JoinColumn(name = "user_id"), uniqueConstraints = {@UniqueConstraint(columnNames = {"user_id", "role"}, name = "user_roles_unique")}) + @Column(name = "role") + @ElementCollection(fetch = FetchType.EAGER) + private Set roles; +} diff --git a/src/main/java/ru/javaops/bootjava/repository/UserRepository.java b/src/main/java/ru/javaops/bootjava/repository/UserRepository.java new file mode 100644 index 0000000..a24e5ad --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/repository/UserRepository.java @@ -0,0 +1,7 @@ +package ru.javaops.bootjava.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import ru.javaops.bootjava.model.User; + +public interface UserRepository extends JpaRepository { +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..e7ac651 --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,30 @@ +# https://docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html +spring: + jpa: + show-sql: true + open-in-view: false + hibernate: + ddl-auto: create-drop + properties: + # http://docs.jboss.org/hibernate/orm/5.3/userguide/html_single/Hibernate_User_Guide.html#configurations + hibernate: + format_sql: true + default_batch_fetch_size: 20 + # https://stackoverflow.com/questions/21257819/what-is-the-difference-between-hibernate-jdbc-fetch-size-and-hibernate-jdbc-batc + jdbc.batch_size: 20 + id.new_generator_mappings: false + datasource: + # ImMemory + url: jdbc:h2:mem:voting + # tcp: jdbc:h2:tcp://localhost:9092/mem:voting + # Absolute path + # url: jdbc:h2:C:/projects/bootjava/restorant-voting/db/voting + # tcp: jdbc:h2:tcp://localhost:9092/C:/projects/bootjava/restorant-voting/db/voting + # Relative path form current dir + # url: jdbc:h2:./db/voting + # Relative path from home + # url: jdbc:h2:~/voting + # tcp: jdbc:h2:tcp://localhost:9092/~/voting + username: sa + password: + h2.console.enabled: true diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql new file mode 100644 index 0000000..4f2e274 --- /dev/null +++ b/src/main/resources/data.sql @@ -0,0 +1,8 @@ +INSERT INTO USERS (EMAIL, FIRST_NAME, LAST_NAME, PASSWORD) +VALUES ('user@gmail.com', 'User_First', 'User_Last', 'password'), + ('admin@javaops.ru', 'Admin_First', 'Admin_Last', 'admin'); + +INSERT INTO USER_ROLE (ROLE, USER_ID) +VALUES ('ROLE_USER', 1), + ('ROLE_ADMIN', 2), + ('ROLE_USER', 2); From bae6e78d1195c4dfbe84961819238290d4112d15 Mon Sep 17 00:00:00 2001 From: Dzmitry Luzko Date: Wed, 9 Feb 2022 01:17:42 +0300 Subject: [PATCH 2/5] Add model query --- .../bootjava/RestaurantVotingApplication.java | 2 +- .../ru/javaops/bootjava/model/BaseEntity.java | 52 +++++++++++++++++++ .../java/ru/javaops/bootjava/model/User.java | 3 +- .../bootjava/repository/UserRepository.java | 11 ++++ src/main/resources/application.yaml | 1 - 5 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 src/main/java/ru/javaops/bootjava/model/BaseEntity.java diff --git a/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java b/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java index d3b1792..fa56af5 100644 --- a/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java +++ b/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java @@ -18,6 +18,6 @@ public static void main(String[] args) { @Override public void run(ApplicationArguments args) { - System.out.println(userRepository.findAll()); + System.out.println(userRepository.findByLastNameContainingIgnoreCase("last")); } } diff --git a/src/main/java/ru/javaops/bootjava/model/BaseEntity.java b/src/main/java/ru/javaops/bootjava/model/BaseEntity.java new file mode 100644 index 0000000..0de1ca0 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/model/BaseEntity.java @@ -0,0 +1,52 @@ +package ru.javaops.bootjava.model; + +import lombok.*; +import org.springframework.data.domain.Persistable; +import org.springframework.data.util.ProxyUtils; +import org.springframework.util.Assert; + +import javax.persistence.*; + +@MappedSuperclass +// https://stackoverflow.com/a/6084701/548473 +@Access(AccessType.FIELD) +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@ToString +public abstract class BaseEntity implements Persistable { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + protected Integer id; + + // doesn't work for hibernate lazy proxy + public int id() { + Assert.notNull(id, "Entity must have id"); + return id; + } + + @Override + public boolean isNew() { + return id == null; + } + + // https://stackoverflow.com/questions/1638723 + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || !getClass().equals(ProxyUtils.getUserClass(o))) { + return false; + } + BaseEntity that = (BaseEntity) o; + return id != null && id.equals(that.id); + } + + @Override + public int hashCode() { + return id == null ? 0 : id; + } +} diff --git a/src/main/java/ru/javaops/bootjava/model/User.java b/src/main/java/ru/javaops/bootjava/model/User.java index 538a112..e0a4912 100644 --- a/src/main/java/ru/javaops/bootjava/model/User.java +++ b/src/main/java/ru/javaops/bootjava/model/User.java @@ -1,7 +1,6 @@ package ru.javaops.bootjava.model; import lombok.*; -import org.springframework.data.jpa.domain.AbstractPersistable; import javax.persistence.*; import javax.validation.constraints.Email; @@ -16,7 +15,7 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @ToString(callSuper = true, exclude = {"password"}) -public class User extends AbstractPersistable { +public class User extends BaseEntity { @Column(name = "email", nullable = false, unique = true) @Email diff --git a/src/main/java/ru/javaops/bootjava/repository/UserRepository.java b/src/main/java/ru/javaops/bootjava/repository/UserRepository.java index a24e5ad..ed8461b 100644 --- a/src/main/java/ru/javaops/bootjava/repository/UserRepository.java +++ b/src/main/java/ru/javaops/bootjava/repository/UserRepository.java @@ -1,7 +1,18 @@ package ru.javaops.bootjava.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; import ru.javaops.bootjava.model.User; +import java.util.List; +import java.util.Optional; + +@Transactional(readOnly = true) public interface UserRepository extends JpaRepository { + + @Query("SELECT u FROM User u WHERE u.email = LOWER(:email)") + Optional findByEmailIgnoreCase(String email); + + List findByLastNameContainingIgnoreCase(String lastName); } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index e7ac651..8ea1053 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -12,7 +12,6 @@ spring: default_batch_fetch_size: 20 # https://stackoverflow.com/questions/21257819/what-is-the-difference-between-hibernate-jdbc-fetch-size-and-hibernate-jdbc-batc jdbc.batch_size: 20 - id.new_generator_mappings: false datasource: # ImMemory url: jdbc:h2:mem:voting From d5778405a6e348770b813c3b43ede7f2c4795d41 Mon Sep 17 00:00:00 2001 From: Dzmitry Luzko Date: Tue, 15 Feb 2022 13:28:24 +0300 Subject: [PATCH 3/5] Add spring security --- pom.xml | 15 +++++++++++ .../ru/javaops/bootjava/model/BaseEntity.java | 2 ++ .../bootjava/repository/UserRepository.java | 3 +++ src/main/resources/application.yaml | 25 +++++++++++++++++++ 4 files changed, 45 insertions(+) diff --git a/pom.xml b/pom.xml index 0fe4458..04dd5c3 100644 --- a/pom.xml +++ b/pom.xml @@ -32,7 +32,22 @@ org.springframework.boot spring-boot-starter-validation + + org.springframework.boot + spring-boot-starter-data-rest + + + org.springframework.boot + spring-boot-starter-security + + com.h2database h2 diff --git a/src/main/java/ru/javaops/bootjava/model/BaseEntity.java b/src/main/java/ru/javaops/bootjava/model/BaseEntity.java index 0de1ca0..e1edadf 100644 --- a/src/main/java/ru/javaops/bootjava/model/BaseEntity.java +++ b/src/main/java/ru/javaops/bootjava/model/BaseEntity.java @@ -1,5 +1,6 @@ package ru.javaops.bootjava.model; +import com.fasterxml.jackson.annotation.JsonIgnore; import lombok.*; import org.springframework.data.domain.Persistable; import org.springframework.data.util.ProxyUtils; @@ -27,6 +28,7 @@ public int id() { return id; } + @JsonIgnore @Override public boolean isNew() { return id == null; diff --git a/src/main/java/ru/javaops/bootjava/repository/UserRepository.java b/src/main/java/ru/javaops/bootjava/repository/UserRepository.java index ed8461b..900ab5e 100644 --- a/src/main/java/ru/javaops/bootjava/repository/UserRepository.java +++ b/src/main/java/ru/javaops/bootjava/repository/UserRepository.java @@ -2,6 +2,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.rest.core.annotation.RestResource; import org.springframework.transaction.annotation.Transactional; import ru.javaops.bootjava.model.User; @@ -11,8 +12,10 @@ @Transactional(readOnly = true) public interface UserRepository extends JpaRepository { + @RestResource(rel = "by-email", path = "by-email") @Query("SELECT u FROM User u WHERE u.email = LOWER(:email)") Optional findByEmailIgnoreCase(String email); + @RestResource(rel = "by-lastname", path = "by-lastname") List findByLastNameContainingIgnoreCase(String lastName); } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 8ea1053..e97590c 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -27,3 +27,28 @@ spring: username: sa password: h2.console.enabled: true + + data.rest: + # https://docs.spring.io/spring-data/rest/docs/current/reference/html/#getting-started.basic-settings + basePath: /api + returnBodyOnCreate: true + + # https://docs.spring.io/spring-boot/docs/current/reference/html/appendix-application-properties.html#security-properties + security: + user: + name: user + password: password + roles: USER + +logging: + level: + root: WARN + ru.javaops.bootjava: DEBUG + org.springframework.security.web.FilterChainProxy: DEBUG + +# Jackson Serialization Issue Resolver +# jackson: +# visibility.field: any +# visibility.getter: none +# visibility.setter: none +# visibility.is-getter: none From 8134edb54437a056588d8ccbfc36cdcf5777e8ed Mon Sep 17 00:00:00 2001 From: Dzmitry Luzko Date: Tue, 15 Feb 2022 14:00:58 +0300 Subject: [PATCH 4/5] Add base security --- .../java/ru/javaops/bootjava/AuthUser.java | 22 ++++++++++++++++++ .../bootjava/RestaurantVotingApplication.java | 11 +-------- .../java/ru/javaops/bootjava/model/Role.java | 14 ++++++++--- .../java/ru/javaops/bootjava/model/User.java | 13 ++++++++++- .../bootjava/util/JsonDeserializers.java | 23 +++++++++++++++++++ src/main/resources/data.sql | 10 ++++---- 6 files changed, 74 insertions(+), 19 deletions(-) create mode 100644 src/main/java/ru/javaops/bootjava/AuthUser.java create mode 100644 src/main/java/ru/javaops/bootjava/util/JsonDeserializers.java diff --git a/src/main/java/ru/javaops/bootjava/AuthUser.java b/src/main/java/ru/javaops/bootjava/AuthUser.java new file mode 100644 index 0000000..1d48a0c --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/AuthUser.java @@ -0,0 +1,22 @@ +package ru.javaops.bootjava; + +import lombok.Getter; +import lombok.ToString; +import org.springframework.lang.NonNull; +import ru.javaops.bootjava.model.User; + +@Getter +@ToString(of = "user") +public class AuthUser extends org.springframework.security.core.userdetails.User { + + private final User user; + + public AuthUser(@NonNull User user) { + super(user.getEmail(), user.getPassword(), user.getRoles()); + this.user = user; + } + + public int id() { + return user.id(); + } +} diff --git a/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java b/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java index fa56af5..3326420 100644 --- a/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java +++ b/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java @@ -1,23 +1,14 @@ package ru.javaops.bootjava; import lombok.AllArgsConstructor; -import org.springframework.boot.ApplicationArguments; -import org.springframework.boot.ApplicationRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import ru.javaops.bootjava.repository.UserRepository; @SpringBootApplication @AllArgsConstructor -public class RestaurantVotingApplication implements ApplicationRunner { - private final UserRepository userRepository; +public class RestaurantVotingApplication { public static void main(String[] args) { SpringApplication.run(RestaurantVotingApplication.class, args); } - - @Override - public void run(ApplicationArguments args) { - System.out.println(userRepository.findByLastNameContainingIgnoreCase("last")); - } } diff --git a/src/main/java/ru/javaops/bootjava/model/Role.java b/src/main/java/ru/javaops/bootjava/model/Role.java index cd76006..e12f117 100644 --- a/src/main/java/ru/javaops/bootjava/model/Role.java +++ b/src/main/java/ru/javaops/bootjava/model/Role.java @@ -1,6 +1,14 @@ package ru.javaops.bootjava.model; -public enum Role { - ROLE_USER, - ROLE_ADMIN +import org.springframework.security.core.GrantedAuthority; + +public enum Role implements GrantedAuthority { + USER, + ADMIN; + + @Override + public String getAuthority() { + // https://stackoverflow.com/a/19542316/548473 + return "ROLE_" + name(); + } } diff --git a/src/main/java/ru/javaops/bootjava/model/User.java b/src/main/java/ru/javaops/bootjava/model/User.java index e0a4912..d90dd55 100644 --- a/src/main/java/ru/javaops/bootjava/model/User.java +++ b/src/main/java/ru/javaops/bootjava/model/User.java @@ -1,11 +1,16 @@ package ru.javaops.bootjava.model; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import lombok.*; +import org.springframework.util.StringUtils; +import ru.javaops.bootjava.util.JsonDeserializers; import javax.persistence.*; import javax.validation.constraints.Email; import javax.validation.constraints.NotEmpty; import javax.validation.constraints.Size; +import java.io.Serializable; import java.util.Set; @Entity @@ -15,7 +20,7 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @ToString(callSuper = true, exclude = {"password"}) -public class User extends BaseEntity { +public class User extends BaseEntity implements Serializable { @Column(name = "email", nullable = false, unique = true) @Email @@ -33,6 +38,8 @@ public class User extends BaseEntity { @Column(name = "password") @Size(max = 256) + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + @JsonDeserialize(using = JsonDeserializers.PasswordDeserializer.class) private String password; @Enumerated(EnumType.STRING) @@ -40,4 +47,8 @@ public class User extends BaseEntity { @Column(name = "role") @ElementCollection(fetch = FetchType.EAGER) private Set roles; + + public void setEmail(String email) { + this.email = StringUtils.hasText(email) ? email.toLowerCase() : null; + } } diff --git a/src/main/java/ru/javaops/bootjava/util/JsonDeserializers.java b/src/main/java/ru/javaops/bootjava/util/JsonDeserializers.java new file mode 100644 index 0000000..a567a40 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/util/JsonDeserializers.java @@ -0,0 +1,23 @@ +package ru.javaops.bootjava.util; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.ObjectCodec; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import ru.javaops.bootjava.config.WebSecurityConfig; + +import java.io.IOException; + +public class JsonDeserializers { + + // https://stackoverflow.com/a/60995048/548473 + public static class PasswordDeserializer extends JsonDeserializer { + public String deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException { + ObjectCodec oc = jsonParser.getCodec(); + JsonNode node = oc.readTree(jsonParser); + String rawPassword = node.asText(); + return WebSecurityConfig.PASSWORD_ENCODER.encode(rawPassword); + } + } +} diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index 4f2e274..f6f9b49 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -1,8 +1,8 @@ INSERT INTO USERS (EMAIL, FIRST_NAME, LAST_NAME, PASSWORD) -VALUES ('user@gmail.com', 'User_First', 'User_Last', 'password'), - ('admin@javaops.ru', 'Admin_First', 'Admin_Last', 'admin'); +VALUES ('user@gmail.com', 'User_First', 'User_Last', '{noop}password'), + ('admin@javaops.ru', 'Admin_First', 'Admin_Last', '{noop}admin'); INSERT INTO USER_ROLE (ROLE, USER_ID) -VALUES ('ROLE_USER', 1), - ('ROLE_ADMIN', 2), - ('ROLE_USER', 2); +VALUES ('USER', 1), + ('ADMIN', 2), + ('USER', 2); From a578f006ddf9ae886ab5fcadacb4b1c3aa2290ef Mon Sep 17 00:00:00 2001 From: Dzmitry Luzko Date: Tue, 15 Feb 2022 18:40:46 +0300 Subject: [PATCH 5/5] Add exception handler --- .../bootjava/config/WebSecurityConfig.java | 59 ++++++++++++++++ .../javaops/bootjava/error/AppException.java | 16 +++++ .../error/IllegalRequestDataException.java | 12 ++++ .../javaops/bootjava/util/ValidationUtil.java | 21 ++++++ .../bootjava/web/AccountController.java | 67 +++++++++++++++++++ .../web/error/GlobalExceptionHandler.java | 28 ++++++++ 6 files changed, 203 insertions(+) create mode 100644 src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java create mode 100644 src/main/java/ru/javaops/bootjava/error/AppException.java create mode 100644 src/main/java/ru/javaops/bootjava/error/IllegalRequestDataException.java create mode 100644 src/main/java/ru/javaops/bootjava/util/ValidationUtil.java create mode 100644 src/main/java/ru/javaops/bootjava/web/AccountController.java create mode 100644 src/main/java/ru/javaops/bootjava/web/error/GlobalExceptionHandler.java diff --git a/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java b/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java new file mode 100644 index 0000000..50b75d9 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java @@ -0,0 +1,59 @@ +package ru.javaops.bootjava.config; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +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.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import ru.javaops.bootjava.AuthUser; +import ru.javaops.bootjava.model.Role; +import ru.javaops.bootjava.model.User; +import ru.javaops.bootjava.repository.UserRepository; + +import java.util.Optional; + +@Configuration +@EnableWebSecurity +@Slf4j +@AllArgsConstructor +public class WebSecurityConfig extends WebSecurityConfigurerAdapter { + + public static final PasswordEncoder PASSWORD_ENCODER = PasswordEncoderFactories.createDelegatingPasswordEncoder(); + private final UserRepository userRepository; + + @Bean + public UserDetailsService userDetailsService() { + return email -> { + log.debug("Authenticating '{}'", email); + Optional optionalUser = userRepository.findByEmailIgnoreCase(email); + return new AuthUser(optionalUser.orElseThrow( + () -> new UsernameNotFoundException("User '" + email + "' was not found"))); + }; + } + + @Autowired + public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception { + auth.userDetailsService(userDetailsService()) + .passwordEncoder(PASSWORD_ENCODER); + } + + @Override + protected void configure(HttpSecurity http) throws Exception { + http.authorizeRequests() + .antMatchers("/api/account/register").anonymous() + .antMatchers("/api/account").hasRole(Role.USER.name()) + .antMatchers("/api/**").hasRole(Role.ADMIN.name()) + .and().httpBasic() + .and().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) + .and().csrf().disable(); + } +} diff --git a/src/main/java/ru/javaops/bootjava/error/AppException.java b/src/main/java/ru/javaops/bootjava/error/AppException.java new file mode 100644 index 0000000..809caad --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/error/AppException.java @@ -0,0 +1,16 @@ +package ru.javaops.bootjava.error; + +import lombok.Getter; +import org.springframework.boot.web.error.ErrorAttributeOptions; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +@Getter +public class AppException extends ResponseStatusException { + private final ErrorAttributeOptions options; + + public AppException(HttpStatus status, String message, ErrorAttributeOptions options) { + super(status, message); + this.options = options; + } +} diff --git a/src/main/java/ru/javaops/bootjava/error/IllegalRequestDataException.java b/src/main/java/ru/javaops/bootjava/error/IllegalRequestDataException.java new file mode 100644 index 0000000..49f9766 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/error/IllegalRequestDataException.java @@ -0,0 +1,12 @@ +package ru.javaops.bootjava.error; + +import org.springframework.boot.web.error.ErrorAttributeOptions; +import org.springframework.http.HttpStatus; + +import static org.springframework.boot.web.error.ErrorAttributeOptions.Include.MESSAGE; + +public class IllegalRequestDataException extends AppException { + public IllegalRequestDataException(String msg) { + super(HttpStatus.UNPROCESSABLE_ENTITY, msg, ErrorAttributeOptions.of(MESSAGE)); + } +} diff --git a/src/main/java/ru/javaops/bootjava/util/ValidationUtil.java b/src/main/java/ru/javaops/bootjava/util/ValidationUtil.java new file mode 100644 index 0000000..ee7eb9a --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/util/ValidationUtil.java @@ -0,0 +1,21 @@ +package ru.javaops.bootjava.util; + +import ru.javaops.bootjava.model.BaseEntity; + +public class ValidationUtil { + + public static void checkNew(BaseEntity entity) { + if (!entity.isNew()) { + throw new IllegalArgumentException(entity + " must be new (id=null)"); + } + } + + // Conservative when you reply, but accept liberally (http://stackoverflow.com/a/32728226/548473) + public static void assureIdConsistent(BaseEntity entity, int id) { + if (entity.isNew()) { + entity.setId(id); + } else if (entity.id() != id) { + throw new IllegalArgumentException(entity + " must has id=" + id); + } + } +} diff --git a/src/main/java/ru/javaops/bootjava/web/AccountController.java b/src/main/java/ru/javaops/bootjava/web/AccountController.java new file mode 100644 index 0000000..f853a8d --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/web/AccountController.java @@ -0,0 +1,67 @@ +package ru.javaops.bootjava.web; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import ru.javaops.bootjava.AuthUser; +import ru.javaops.bootjava.model.Role; +import ru.javaops.bootjava.model.User; +import ru.javaops.bootjava.repository.UserRepository; +import ru.javaops.bootjava.util.ValidationUtil; + +import javax.validation.Valid; +import java.net.URI; +import java.util.Set; + +@RestController +@RequestMapping(value = "/api/account") +@AllArgsConstructor +@Slf4j +public class AccountController { + + private final UserRepository userRepository; + + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + public User get(@AuthenticationPrincipal AuthUser authUser) { + log.info("get {}", authUser); + return authUser.getUser(); + } + + @DeleteMapping + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@AuthenticationPrincipal AuthUser authUser) { + log.info("delete {}", authUser); + userRepository.deleteById(authUser.id()); + } + + @PostMapping(value = "/register", consumes = MediaType.APPLICATION_JSON_VALUE) + @ResponseStatus(value = HttpStatus.CREATED) + public ResponseEntity register(@Valid @RequestBody User user) { + log.info("register {}", user); + ValidationUtil.checkNew(user); + user.setRoles(Set.of(Role.USER)); + user = userRepository.save(user); + URI uriOfNewResource = ServletUriComponentsBuilder.fromCurrentContextPath() + .path("/api/account") + .build().toUri(); + return ResponseEntity.created(uriOfNewResource).body(user); + } + + @PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.NO_CONTENT) + public void update(@Valid @RequestBody User user, @AuthenticationPrincipal AuthUser authUser) { + log.info("update {} to {}", authUser, user); + User oldUser = authUser.getUser(); + ValidationUtil.assureIdConsistent(user, oldUser.id()); + user.setRoles(oldUser.getRoles()); + if (user.getPassword() == null) { + user.setPassword(oldUser.getPassword()); + } + userRepository.save(user); + } +} diff --git a/src/main/java/ru/javaops/bootjava/web/error/GlobalExceptionHandler.java b/src/main/java/ru/javaops/bootjava/web/error/GlobalExceptionHandler.java new file mode 100644 index 0000000..9a917cb --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/web/error/GlobalExceptionHandler.java @@ -0,0 +1,28 @@ +package ru.javaops.bootjava.web.error; + +import lombok.AllArgsConstructor; +import org.springframework.boot.web.servlet.error.ErrorAttributes; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; +import ru.javaops.bootjava.error.AppException; + +import java.util.Map; + +@RestControllerAdvice +@AllArgsConstructor +public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + private final ErrorAttributes errorAttributes; + + @ExceptionHandler(AppException.class) + public ResponseEntity> appException(AppException ex, WebRequest request) { + Map body = errorAttributes.getErrorAttributes(request, ex.getOptions()); + HttpStatus status = ex.getStatus(); + body.put("status", status.value()); + body.put("error", status.getReasonPhrase()); + return ResponseEntity.status(status).body(body); + } +}