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