diff --git a/pom.xml b/pom.xml index ca66a72..04dd5c3 100644 --- a/pom.xml +++ b/pom.xml @@ -28,11 +28,29 @@ org.springframework.boot spring-boot-starter-web + + 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 - runtime org.projectlombok @@ -53,6 +71,14 @@ org.springframework.boot spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + 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..d4bf023 --- /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(); + } +} \ No newline at end of file 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/config/WebSecurityConfig.java b/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java new file mode 100644 index 0000000..b6438fc --- /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(); + } +} \ No newline at end of file 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..cb18581 --- /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)); + } +} \ No newline at end of file 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..72ed0fc --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/model/BaseEntity.java @@ -0,0 +1,54 @@ +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; +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; + } + + @JsonIgnore + @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; + } +} \ No newline at end of file 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..08bc76d --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/model/Role.java @@ -0,0 +1,14 @@ +package ru.javaops.bootjava.model; + +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(); + } +} \ No newline at end of file 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..c61b200 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/model/User.java @@ -0,0 +1,54 @@ +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 +@Table(name = "users") +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@ToString(callSuper = true, exclude = {"password"}) +public class User extends BaseEntity implements Serializable { + + @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) + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + @JsonDeserialize(using = JsonDeserializers.PasswordDeserializer.class) + 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; + + public void setEmail(String email) { + this.email = StringUtils.hasText(email) ? email.toLowerCase() : null; + } +} \ No newline at end of file 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..dc2a413 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/repository/UserRepository.java @@ -0,0 +1,21 @@ +package ru.javaops.bootjava.repository; + +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; + +import java.util.List; +import java.util.Optional; + +@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); +} \ No newline at end of file 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/java/ru/javaops/bootjava/util/ValidationUtil.java b/src/main/java/ru/javaops/bootjava/util/ValidationUtil.java new file mode 100644 index 0000000..5f709d4 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/util/ValidationUtil.java @@ -0,0 +1,22 @@ +package ru.javaops.bootjava.util; + +import ru.javaops.bootjava.error.IllegalRequestDataException; +import ru.javaops.bootjava.model.BaseEntity; + +public class ValidationUtil { + + public static void checkNew(BaseEntity entity) { + if (!entity.isNew()) { + throw new IllegalRequestDataException(entity.getClass().getSimpleName() + " 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 IllegalRequestDataException(entity.getClass().getSimpleName() + " must has id=" + id); + } + } +} \ No newline at end of file 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..a7c9a40 --- /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); + } +} 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..993777a --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,54 @@ +# 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 + 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 + + 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 \ No newline at end of file diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql new file mode 100644 index 0000000..778d2f3 --- /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', '{noop}password'), + ('admin@javaops.ru', 'Admin_First', 'Admin_Last', '{noop}admin'); + +INSERT INTO USER_ROLE (ROLE, USER_ID) +VALUES ('USER', 1), + ('ADMIN', 2), + ('USER', 2); \ No newline at end of file