diff --git a/build.gradle b/build.gradle index e12cdfb..482ecc4 100644 --- a/build.gradle +++ b/build.gradle @@ -6,18 +6,24 @@ plugins { group = 'ru.ulstu.is' version = '0.0.1-SNAPSHOT' -sourceCompatibility = '17' +sourceCompatibility = '19' repositories { mavenCentral() } dependencies { + annotationProcessor "org.springframework.boot:spring-boot-configuration-processor" implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'com.h2database:h2:2.1.210' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'com.h2database:h2:2.1.210' + implementation 'com.auth0:java-jwt:4.4.0' + implementation 'org.springframework.boot:spring-boot-devtools' + implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5' implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect' + implementation 'jakarta.validation:jakarta.validation-api:3.0.0' implementation 'org.webjars:bootstrap:5.1.3' implementation 'org.webjars:jquery:3.6.0' implementation 'org.webjars:font-awesome:6.1.0' diff --git a/frontend/spa-vue/src/components/Header.vue b/frontend/spa-vue/src/components/Header.vue index 82d0aa0..61658f3 100644 --- a/frontend/spa-vue/src/components/Header.vue +++ b/frontend/spa-vue/src/components/Header.vue @@ -7,6 +7,8 @@ Производители Категории Продукты + Users + @@ -36,6 +38,15 @@ \ No newline at end of file diff --git a/frontend/spa-vue/src/pages/categories.vue b/frontend/spa-vue/src/pages/categories.vue index a427873..5306fb1 100644 --- a/frontend/spa-vue/src/pages/categories.vue +++ b/frontend/spa-vue/src/pages/categories.vue @@ -104,11 +104,38 @@ export default { category: new Category(), editedCategory: new Category(), products: [], + postParams: { + method:"POST", + headers:{ + "Content-Type":"application/json", + "Authorization": "Bearer " + localStorage.getItem("token"), + } + }, + putParams: { + method:"PUT", + headers:{ + "Content-Type":"application/json", + "Authorization": "Bearer " + localStorage.getItem("token"), + }, + }, + delParams: { + method:"DELETE", + headers:{ + "Content-Type":"application/json", + "Authorization": "Bearer " + localStorage.getItem("token"), + } + }, + getParams: { + method:"GET", + headers:{ + "Authorization": "Bearer " + localStorage.getItem("token"), + } + }, } }, methods: { getCategories(){ - axios.get(this.URL + "category") + axios.get(this.URL + "category", this.getParams) .then(response => { this.categories = response.data; console.log(response.data); @@ -120,7 +147,7 @@ export default { addCategory(category) { console.log(category); axios - .post(this.URL + "category", category) + .post(this.URL + "category", category, this.postParams) .then(() => { this.getCategories(); this.closeModal(); @@ -130,7 +157,7 @@ export default { }); }, deleteCategory(id){ - axios.delete(this.URL + `category/${id}`) + axios.delete(this.URL + `category/${id}`, this.delParams) .then(() =>{ this.getCategories(); }) @@ -150,7 +177,7 @@ export default { document.getElementById("editModal").style.display = "none"; }, editCategory(category) { - axios.put(this.URL + `category/${category.id}`, category) + axios.put(this.URL + `category/${category.id}`, category, this.putParams) .then(() => { const index = this.categories.findIndex((s) => s.id === category.id); if (index !== -1) { diff --git a/frontend/spa-vue/src/pages/error.vue b/frontend/spa-vue/src/pages/error.vue new file mode 100644 index 0000000..f6d5da1 --- /dev/null +++ b/frontend/spa-vue/src/pages/error.vue @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/frontend/spa-vue/src/pages/login.vue b/frontend/spa-vue/src/pages/login.vue new file mode 100644 index 0000000..c8c850f --- /dev/null +++ b/frontend/spa-vue/src/pages/login.vue @@ -0,0 +1,83 @@ + + + \ No newline at end of file diff --git a/frontend/spa-vue/src/pages/manufacturers.vue b/frontend/spa-vue/src/pages/manufacturers.vue index ba52830..7a86b32 100644 --- a/frontend/spa-vue/src/pages/manufacturers.vue +++ b/frontend/spa-vue/src/pages/manufacturers.vue @@ -71,11 +71,38 @@ export default { manufacturers: [], URL: "http://localhost:8080/api/", manufacturer: new Manufacturer(), + postParams: { + method:"POST", + headers:{ + "Content-Type":"application/json", + "Authorization": "Bearer " + localStorage.getItem("token"), + } + }, + putParams: { + method:"PUT", + headers:{ + "Content-Type":"application/json", + "Authorization": "Bearer " + localStorage.getItem("token"), + }, + }, + delParams: { + method:"DELETE", + headers:{ + "Content-Type":"application/json", + "Authorization": "Bearer " + localStorage.getItem("token"), + } + }, + getParams: { + method:"GET", + headers:{ + "Authorization": "Bearer " + localStorage.getItem("token"), + } + }, } }, methods: { getManufacturers(){ - axios.get(this.URL + "manufacturer") + axios.get(this.URL + "manufacturer", this.getParams) .then(response => { this.manufacturers = response.data; console.log(response.data); @@ -86,7 +113,7 @@ export default { }, addManufacturer(manufacturer){ console.log(this.manufacturers); - axios.post(this.URL + "manufacturer", manufacturer) + axios.post(this.URL + "manufacturer", manufacturer, this.postParams) .then(() => { this.getManufacturers(); this.closeModal(); @@ -96,13 +123,13 @@ export default { }); }, deleteManufacturer(id){ - axios.delete(this.URL + `manufacturer/${id}`) + axios.delete(this.URL + `manufacturer/${id}`, this.delParams) .then(() =>{ this.getManufacturers(); }) }, editManufacturer(manufacturer){ - axios.put(this.URL + `manufacturer/${manufacturer.id}`, manufacturer) + axios.put(this.URL + `manufacturer/${manufacturer.id}`, manufacturer, this.putParams) .then(() =>{ const index = this.manufacturers.findIndex((m) => m.id === manufacturer.id); if (index !== -1) { diff --git a/frontend/spa-vue/src/pages/products.vue b/frontend/spa-vue/src/pages/products.vue index 37bff91..88cebd2 100644 --- a/frontend/spa-vue/src/pages/products.vue +++ b/frontend/spa-vue/src/pages/products.vue @@ -136,11 +136,38 @@ export default { URL: "http://localhost:8080/api/", product: new Product(), editedProduct: new Product(), + postParams: { + method:"POST", + headers:{ + "Content-Type":"application/json", + "Authorization": "Bearer " + localStorage.getItem("token"), + } + }, + putParams: { + method:"PUT", + headers:{ + "Content-Type":"application/json", + "Authorization": "Bearer " + localStorage.getItem("token"), + }, + }, + delParams: { + method:"DELETE", + headers:{ + "Content-Type":"application/json", + "Authorization": "Bearer " + localStorage.getItem("token"), + } + }, + getParams: { + method:"GET", + headers:{ + "Authorization": "Bearer " + localStorage.getItem("token"), + } + }, } }, methods: { getProducts(){ - axios.get(this.URL + "product") + axios.get(this.URL + "product", this.getParams) .then(response => { this.products = response.data; console.log(response.data); @@ -150,7 +177,7 @@ export default { }); }, getCategories(){ - axios.get(this.URL + "category") + axios.get(this.URL + "category", this.getParams) .then(response => { this.categories = response.data; console.log(response.data); @@ -163,7 +190,7 @@ export default { await this.toBase64(); this.product.price = parseInt(this.product.price); console.log(this.product); - axios.post(this.URL + "product", this.product) + axios.post(this.URL + "product", this.product, this.postParams) .then(() => { this.getProducts(); this.closeModal(); @@ -173,7 +200,7 @@ export default { }); }, deleteProduct(id){ - axios.delete(this.URL + `product/${id}`) + axios.delete(this.URL + `product/${id}`, this.delParams) .then(() =>{ this.getProducts(); }) @@ -181,7 +208,7 @@ export default { async editProduct(product){ if(product.photo === undefined) await this.toBase64(); console.log(product); - axios.put(this.URL + `product/${product.id}`, product) + axios.put(this.URL + `product/${product.id}`, product, this.putParams) .then(() =>{ const index = this.products.findIndex((s) => s.id === product.id); if (index !== -1) { @@ -242,7 +269,7 @@ export default { }); }, getProductManufacturers(){ - axios.get(this.URL + `product/${this.product.id}/manufacturers`) + axios.get(this.URL + `product/${this.product.id}/manufacturers`, this.getParams) .then(response => { this.productManufacturers = response.data; console.log(response.data); @@ -252,7 +279,7 @@ export default { }); }, addManufacturer(){ - axios.post(this.URL + `product/${this.product.id}/Manufacturer/${this.manufacturerId}`) + axios.post(this.URL + `product/${this.product.id}/Manufacturer/${this.manufacturerId}`, this.postParams) .then(() => { this.getProductManufacturers(); }) @@ -261,7 +288,7 @@ export default { }); }, removeManufacturer(id){ - axios.delete(this.URL + `product/${this.product.id}/Manufacturer/${id}`) + axios.delete(this.URL + `product/${this.product.id}/Manufacturer/${id}`, this.delParams) .then(() =>{ this.getProductManufacturers(); }) diff --git a/frontend/spa-vue/src/pages/registration.vue b/frontend/spa-vue/src/pages/registration.vue new file mode 100644 index 0000000..ed28f1e --- /dev/null +++ b/frontend/spa-vue/src/pages/registration.vue @@ -0,0 +1,46 @@ + + \ No newline at end of file diff --git a/frontend/spa-vue/src/pages/users.vue b/frontend/spa-vue/src/pages/users.vue new file mode 100644 index 0000000..3543375 --- /dev/null +++ b/frontend/spa-vue/src/pages/users.vue @@ -0,0 +1,52 @@ + + + \ No newline at end of file diff --git a/frontend/spa-vue/src/routes.js b/frontend/spa-vue/src/routes.js index bf9f72a..0211afd 100644 --- a/frontend/spa-vue/src/routes.js +++ b/frontend/spa-vue/src/routes.js @@ -1,12 +1,20 @@ import manufacturers from './pages/manufacturers.vue' import categories from './pages/categories.vue' import products from './pages/products.vue' +import users from "./pages/users.vue"; +import login from "./pages/login.vue"; +import registration from "./pages/registration.vue"; +import Error from "./pages/Error.vue"; import {createRouter, createWebHistory} from "vue-router" const routes = [ {path: '/manufacturer', component: manufacturers}, {path: '/category', component: categories}, {path: '/product', component: products}, + {path: "/users", component: users, meta: { requiresAuth: true, requiresAdmin: true }}, + {path: "/login", component: login}, + {path: "/registration", component: registration}, + {path: "/error", component: Error, meta: { requiresAuth: true }}, ] const router = createRouter({ @@ -15,4 +23,22 @@ const router = createRouter({ routes }) +router.beforeEach((to, from, next) => { + const isAuthenticated = localStorage.getItem("token"); + if (to.matched.some((route) => route.meta.requiresAuth)) { + if (!isAuthenticated) { + next("/login"); + return; + } + } + const isAdmin = localStorage.getItem("role") === "ADMIN"; + if (to.matched.some((route) => route.meta.requiresAdmin)) { + if (!isAdmin) { + next("/error"); + return; + } + } + next(); +}); + export default router; \ No newline at end of file diff --git a/src/main/java/ru/ulstu/is/sbapp/HardwareShop/controller/UserController.java b/src/main/java/ru/ulstu/is/sbapp/HardwareShop/controller/UserController.java new file mode 100644 index 0000000..03db6ba --- /dev/null +++ b/src/main/java/ru/ulstu/is/sbapp/HardwareShop/controller/UserController.java @@ -0,0 +1,64 @@ +package ru.ulstu.is.sbapp.HardwareShop.controller; + +import jakarta.validation.Valid; +import jakarta.validation.ValidationException; +import org.springframework.security.access.annotation.Secured; +import org.springframework.web.bind.annotation.*; +import ru.ulstu.is.sbapp.HardwareShop.models.User; +import ru.ulstu.is.sbapp.HardwareShop.models.UserRole; +import ru.ulstu.is.sbapp.HardwareShop.services.UserService; + +import java.util.List; + +@RestController +public class UserController { + public static final String URL_LOGIN = "/jwt/login"; + public static final String URL_SIGN_UP = "/sign_up"; + public static final String URL_WHO_AM_I = "/who_am_i"; + private final UserService userService; + + public UserController(UserService userService) { + this.userService = userService; + } + @PostMapping(URL_LOGIN) + public String login(@RequestBody @Valid UserDto userDto) { + return userService.loginAndGetToken(userDto); + } + @GetMapping(URL_WHO_AM_I) + public String role(@RequestParam("userLogin") String userLogin) { + return userService.findByLogin(userLogin).getRole().name(); + } + @PostMapping(URL_SIGN_UP) + public String signUp(@RequestBody @Valid UserSignUpDTO userSignupDto) { + try { + final User user = userService.addUser(userSignupDto.getLogin(), + userSignupDto.getPassword(), userSignupDto.getPasswordConfirm(), UserRole.USER); + return "created " + user.getLogin(); + } catch (ValidationException e) { + return e.getMessage(); + } + } + @GetMapping("/{id}") + @Secured({UserRole.AsString.ADMIN}) + public UserDto getUser(@PathVariable Long id) { + return new UserDto(userService.findUser(id)); + } + @GetMapping("/") + @Secured({UserRole.AsString.ADMIN}) + public List getUsers() { + return userService.findAllUsers().stream() + .map(UserDto::new) + .toList(); + } + + @PutMapping("/{id}") + public UserDto updateUser(@RequestBody @Valid UserDto userDto){ + return new UserDto(userService.updateUser(userDto)); + } + + + @DeleteMapping("/{id}") + public void deleteUser(@PathVariable Long id) { + userService.deleteUser(id); + } +} \ No newline at end of file diff --git a/src/main/java/ru/ulstu/is/sbapp/HardwareShop/controller/UserDto.java b/src/main/java/ru/ulstu/is/sbapp/HardwareShop/controller/UserDto.java new file mode 100644 index 0000000..049af37 --- /dev/null +++ b/src/main/java/ru/ulstu/is/sbapp/HardwareShop/controller/UserDto.java @@ -0,0 +1,50 @@ +package ru.ulstu.is.sbapp.HardwareShop.controller; + +import ru.ulstu.is.sbapp.HardwareShop.models.User; +import ru.ulstu.is.sbapp.HardwareShop.models.UserRole; + +public class UserDto { + private long id; + private String login; + private UserRole role; + private String password; + + public UserDto(){} + public UserDto(User user) { + this.id = user.getId(); + this.login = user.getLogin(); + this.role = user.getRole(); + this.password = user.getPassword(); + } + + public long getId() { + return id; + } + + public String getLogin() { + return login; + } + + public UserRole getRole() { + return role; + } + + public String getPassword() + { + return password; + } + + public void setId(Long id) { + this.id = id; + } + + public void setLogin(String login) + { + this.login = login; + } + + public void setPassword(String password) + { + this.password = password; + } +} \ No newline at end of file diff --git a/src/main/java/ru/ulstu/is/sbapp/HardwareShop/controller/UserSignUpDTO.java b/src/main/java/ru/ulstu/is/sbapp/HardwareShop/controller/UserSignUpDTO.java new file mode 100644 index 0000000..8235bf3 --- /dev/null +++ b/src/main/java/ru/ulstu/is/sbapp/HardwareShop/controller/UserSignUpDTO.java @@ -0,0 +1,40 @@ +package ru.ulstu.is.sbapp.HardwareShop.controller; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public class UserSignUpDTO { + @NotBlank + @Size(min = 3, max = 64) + private String login; + @NotBlank + @Size(min = 6, max = 64) + private String password; + @NotBlank + @Size(min = 6, max = 64) + private String passwordConfirm; + + public String getLogin() { + return login; + } + + public void setLogin(String login) { + this.login = login; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getPasswordConfirm() { + return passwordConfirm; + } + + public void setPasswordConfirm(String passwordConfirm) { + this.passwordConfirm = passwordConfirm; + } +} \ No newline at end of file diff --git a/src/main/java/ru/ulstu/is/sbapp/HardwareShop/models/User.java b/src/main/java/ru/ulstu/is/sbapp/HardwareShop/models/User.java new file mode 100644 index 0000000..980e85e --- /dev/null +++ b/src/main/java/ru/ulstu/is/sbapp/HardwareShop/models/User.java @@ -0,0 +1,91 @@ +package ru.ulstu.is.sbapp.HardwareShop.models; + + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import ru.ulstu.is.sbapp.HardwareShop.controller.UserSignUpDTO; + +import javax.persistence.*; +import java.util.Objects; + +@Entity +@Table(name = "users") +public class User { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Long id; + @Column(nullable = false, unique = true, length = 64) + @NotBlank + @Size(min = 3, max = 64) + private String login; + @Column(nullable = false, length = 64) + @NotBlank + @Size(min = 6, max = 64) + private String password; + private UserRole role; + + public User() { + } + + public User(String login, String password) { + this(login, password, UserRole.USER); + } + + public User(String login, String password, UserRole role) { + this.login = login; + this.password = password; + this.role = role; + } + + public User(UserSignUpDTO userDto){ + this.login = userDto.getLogin(); + this.password = userDto.getPassword(); + this.role = UserRole.USER; + } + + public Long getId() { + return id; + } + + public String getLogin() { + return login; + } + + public void setLogin(String login) { + this.login = login; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public UserRole getRole() { + return role; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + User user = (User) o; + return Objects.equals(id, user.id) && Objects.equals(login, user.login); + } + + @Override + public int hashCode() { + return Objects.hash(id, login); + } + @Override + public String toString() { + return "User{" + + "id=" + id + + ", login='" + login + '\'' + + ", password='" + password + '\'' + + ", role='" + role + '\'' + + '}'; + } +} \ No newline at end of file diff --git a/src/main/java/ru/ulstu/is/sbapp/HardwareShop/models/UserRole.java b/src/main/java/ru/ulstu/is/sbapp/HardwareShop/models/UserRole.java new file mode 100644 index 0000000..174da83 --- /dev/null +++ b/src/main/java/ru/ulstu/is/sbapp/HardwareShop/models/UserRole.java @@ -0,0 +1,20 @@ +package ru.ulstu.is.sbapp.HardwareShop.models; + +import org.springframework.security.core.GrantedAuthority; + +public enum UserRole implements GrantedAuthority { + ADMIN, + USER; + + private static final String PREFIX = "ROLE_"; + + @Override + public String getAuthority() { + return PREFIX + this.name(); + } + + public static final class AsString { + public static final String ADMIN = PREFIX + "ADMIN"; + public static final String USER = PREFIX + "USER"; + } +} \ No newline at end of file diff --git a/src/main/java/ru/ulstu/is/sbapp/HardwareShop/repository/UserRepository.java b/src/main/java/ru/ulstu/is/sbapp/HardwareShop/repository/UserRepository.java new file mode 100644 index 0000000..02f2524 --- /dev/null +++ b/src/main/java/ru/ulstu/is/sbapp/HardwareShop/repository/UserRepository.java @@ -0,0 +1,8 @@ +package ru.ulstu.is.sbapp.HardwareShop.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import ru.ulstu.is.sbapp.HardwareShop.models.User; + +public interface UserRepository extends JpaRepository { + User findOneByLoginIgnoreCase(String login); +} \ No newline at end of file diff --git a/src/main/java/ru/ulstu/is/sbapp/HardwareShop/services/UserNotFoundException.java b/src/main/java/ru/ulstu/is/sbapp/HardwareShop/services/UserNotFoundException.java new file mode 100644 index 0000000..a6b39ab --- /dev/null +++ b/src/main/java/ru/ulstu/is/sbapp/HardwareShop/services/UserNotFoundException.java @@ -0,0 +1,7 @@ +package ru.ulstu.is.sbapp.HardwareShop.services; + +public class UserNotFoundException extends RuntimeException{ + public UserNotFoundException(Long id) { + super(String.format("User with id [%s] is not found", id)); + } +} \ No newline at end of file diff --git a/src/main/java/ru/ulstu/is/sbapp/HardwareShop/services/UserService.java b/src/main/java/ru/ulstu/is/sbapp/HardwareShop/services/UserService.java new file mode 100644 index 0000000..6d497de --- /dev/null +++ b/src/main/java/ru/ulstu/is/sbapp/HardwareShop/services/UserService.java @@ -0,0 +1,129 @@ +package ru.ulstu.is.sbapp.HardwareShop.services; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import ru.ulstu.is.sbapp.HardwareShop.controller.UserDto; +import ru.ulstu.is.sbapp.HardwareShop.controller.UserSignUpDTO; +import ru.ulstu.is.sbapp.HardwareShop.models.User; +import ru.ulstu.is.sbapp.HardwareShop.models.UserRole; +import ru.ulstu.is.sbapp.HardwareShop.repository.UserRepository; + +import jakarta.validation.ValidationException; +import ru.ulstu.is.sbapp.jwt.JwtException; +import ru.ulstu.is.sbapp.jwt.JwtProvider; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +@Service +public class UserService implements UserDetailsService { + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final JwtProvider jwtProvider; + + public UserService(UserRepository userRepository, + PasswordEncoder passwordEncoder, + JwtProvider jwtProvider){ + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + this.jwtProvider = jwtProvider; + } + public User findByLogin(String login) { + return userRepository.findOneByLoginIgnoreCase(login); + } + public Page findAllPages(int page, int size) { + return userRepository.findAll(PageRequest.of(page - 1, size, Sort.by("id").ascending())); + } + @Transactional + public User addUser(UserSignUpDTO userDto){ + final User user = new User(userDto); + userRepository.save(user); + return user; + } + @Transactional + public User addUser(String login, String password, + String passwordConfirm, + UserRole role){ + if (findByLogin(login) != null) { + throw new ValidationException(String.format("User '%s' already exists", login)); + } + if (!Objects.equals(password, passwordConfirm)) { + throw new ValidationException("Passwords not equals"); + } + final User user = new User(login,passwordEncoder.encode(password),role); + return userRepository.save(user); + } + @Transactional(readOnly = true) + public User findUser(Long id) { + final Optional user = userRepository.findById(id); + return user.orElseThrow(() -> new UserNotFoundException(id)); + } + + @Transactional(readOnly = true) + public List findAllUsers() { + return userRepository.findAll(); + } + + @Transactional + public User updateUser(UserDto userDto) { + final User currentUser = findUser(userDto.getId()); + currentUser.setLogin(userDto.getLogin()); + currentUser.setPassword(userDto.getPassword()); + return userRepository.save(currentUser); + } + @Transactional + public User updateUser(Long id,String login, String password) { + if (!StringUtils.hasText(login) || !StringUtils.hasText(password)) { + throw new IllegalArgumentException("User name, login or password is null or empty"); + } + final User currentUser = findUser(id); + currentUser.setLogin(login); + currentUser.setPassword(password); + return userRepository.save(currentUser); + } + @Transactional + public void deleteUser(Long id) { + userRepository.deleteById(id); + } + + @Transactional + public void deleteAllUsers() { + userRepository.deleteAll(); + } + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + final User userEntity = findByLogin(username); + if (userEntity == null) { + throw new UsernameNotFoundException(username); + } + return new org.springframework.security.core.userdetails.User( + userEntity.getLogin(), userEntity.getPassword(), Collections.singleton(userEntity.getRole())); + } + public String loginAndGetToken(UserDto userDto) { + final User user = findByLogin(userDto.getLogin()); + if (user == null) { + } + if (!passwordEncoder.matches(userDto.getPassword(), user.getPassword())) { + } + return jwtProvider.generateToken(user.getLogin()); + } + public UserDetails loadUserByToken(String token) throws UsernameNotFoundException { + if (!jwtProvider.isTokenValid(token)) { + throw new JwtException("Bad token"); + } + final String userLogin = jwtProvider.getLoginFromToken(token) + .orElseThrow(() -> new JwtException("Token is not contain Login")); + return loadUserByUsername(userLogin); + } +} \ No newline at end of file diff --git a/src/main/java/ru/ulstu/is/sbapp/PasswordEncoderConfiguration.java b/src/main/java/ru/ulstu/is/sbapp/PasswordEncoderConfiguration.java new file mode 100644 index 0000000..e5540d0 --- /dev/null +++ b/src/main/java/ru/ulstu/is/sbapp/PasswordEncoderConfiguration.java @@ -0,0 +1,14 @@ +package ru.ulstu.is.sbapp; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class PasswordEncoderConfiguration { + @Bean + public PasswordEncoder createPasswordEncoder() { + return new BCryptPasswordEncoder(); + } +} \ No newline at end of file diff --git a/src/main/java/ru/ulstu/is/sbapp/SecurityConfiguration.java b/src/main/java/ru/ulstu/is/sbapp/SecurityConfiguration.java new file mode 100644 index 0000000..17fb876 --- /dev/null +++ b/src/main/java/ru/ulstu/is/sbapp/SecurityConfiguration.java @@ -0,0 +1,83 @@ +package ru.ulstu.is.sbapp; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +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.WebSecurityCustomizer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import ru.ulstu.is.sbapp.HardwareShop.controller.UserController; +import ru.ulstu.is.sbapp.HardwareShop.models.UserRole; +import ru.ulstu.is.sbapp.HardwareShop.services.UserService; +import ru.ulstu.is.sbapp.jwt.JwtFilter; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity( + securedEnabled = true +) +public class SecurityConfiguration { + private final Logger log = LoggerFactory.getLogger(SecurityConfiguration.class); + private static final String LOGIN_URL = "/login"; + public static final String SPA_URL_MASK = "/{path:[^\\.]*}"; + + private final UserService userService; + private final JwtFilter jwtFilter; + + public SecurityConfiguration(UserService userService) + { + this.userService = userService; + this.jwtFilter = new JwtFilter(userService); + createAdminOnStartup(); + } + + private void createAdminOnStartup() { + final String admin = "admin"; + if (userService.findByLogin(admin) == null) { + log.info("Admin user successfully created"); + userService.addUser(admin, admin, admin, UserRole.ADMIN); + } + } + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.cors() + .and() + .csrf().disable() + .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) + .and() + .authorizeHttpRequests() + .antMatchers("/", SPA_URL_MASK).permitAll() + .antMatchers(HttpMethod.POST, UserController.URL_LOGIN).permitAll() + .antMatchers(HttpMethod.POST, UserController.URL_SIGN_UP).permitAll() + .antMatchers(HttpMethod.POST, UserController.URL_WHO_AM_I).permitAll() + .anyRequest() + .authenticated() + .and() + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) + .anonymous(); + return http.userDetailsService(userService).build(); + } + + @Bean + public WebSecurityCustomizer webSecurityCustomizer() { + return (web) -> web.ignoring() + .antMatchers(HttpMethod.OPTIONS, "/**") + .antMatchers("/*.js") + .antMatchers("/*.html") + .antMatchers("/*.css") + .antMatchers("/assets/**") + .antMatchers("/favicon.ico") + .antMatchers("/.js", "/.css") + .antMatchers("/swagger-ui/index.html") + .antMatchers("/webjars/**") + .antMatchers("/swagger-resources/**") + .antMatchers("/v3/api-docs/**") + .antMatchers("/h2-console/**"); + } +} \ No newline at end of file diff --git a/src/main/java/ru/ulstu/is/sbapp/jwt/JwtException.java b/src/main/java/ru/ulstu/is/sbapp/jwt/JwtException.java new file mode 100644 index 0000000..63c456c --- /dev/null +++ b/src/main/java/ru/ulstu/is/sbapp/jwt/JwtException.java @@ -0,0 +1,11 @@ +package ru.ulstu.is.sbapp.jwt; + +public class JwtException extends RuntimeException { + public JwtException(Throwable throwable) { + super(throwable); + } + + public JwtException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/ru/ulstu/is/sbapp/jwt/JwtFilter.java b/src/main/java/ru/ulstu/is/sbapp/jwt/JwtFilter.java new file mode 100644 index 0000000..7cfa1bf --- /dev/null +++ b/src/main/java/ru/ulstu/is/sbapp/jwt/JwtFilter.java @@ -0,0 +1,72 @@ +package ru.ulstu.is.sbapp.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.GenericFilterBean; +import ru.ulstu.is.sbapp.HardwareShop.services.UserService; + +import java.io.IOException; + +public class JwtFilter extends GenericFilterBean { + private static final String AUTHORIZATION = "Authorization"; + public static final String TOKEN_BEGIN_STR = "Bearer "; + + private final UserService userService; + + public JwtFilter(UserService userService) { + this.userService = userService; + } + + private String getTokenFromRequest(HttpServletRequest request) { + String bearer = request.getHeader(AUTHORIZATION); + if (StringUtils.hasText(bearer) && bearer.startsWith(TOKEN_BEGIN_STR)) { + return bearer.substring(TOKEN_BEGIN_STR.length()); + } + return null; + } + + private void raiseException(ServletResponse response, int status, String message) throws IOException { + if (response instanceof final HttpServletResponse httpResponse) { + httpResponse.setContentType(MediaType.APPLICATION_JSON_VALUE); + httpResponse.setStatus(status); + final byte[] body = new ObjectMapper().writeValueAsBytes(message); + response.getOutputStream().write(body); + } + } + + @Override + public void doFilter(ServletRequest request, + ServletResponse response, + FilterChain chain) throws IOException, ServletException { + if (request instanceof final HttpServletRequest httpRequest) { + final String token = getTokenFromRequest(httpRequest); + if (StringUtils.hasText(token)) { + try { + final UserDetails user = userService.loadUserByToken(token); + final UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(auth); + } catch (JwtException e) { + raiseException(response, HttpServletResponse.SC_UNAUTHORIZED, e.getMessage()); + return; + } catch (Exception e) { + e.printStackTrace(); + raiseException(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + String.format("Internal error: %s", e.getMessage())); + return; + } + } + } + chain.doFilter(request, response); + } +} \ No newline at end of file diff --git a/src/main/java/ru/ulstu/is/sbapp/jwt/JwtProperties.java b/src/main/java/ru/ulstu/is/sbapp/jwt/JwtProperties.java new file mode 100644 index 0000000..32acfcf --- /dev/null +++ b/src/main/java/ru/ulstu/is/sbapp/jwt/JwtProperties.java @@ -0,0 +1,27 @@ +package ru.ulstu.is.sbapp.jwt; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "jwt", ignoreInvalidFields = true) +public class JwtProperties { + private String devToken = ""; + private Boolean isDev = true; + + public String getDevToken() { + return devToken; + } + + public void setDevToken(String devToken) { + this.devToken = devToken; + } + + public Boolean isDev() { + return isDev; + } + + public void setDev(Boolean dev) { + isDev = dev; + } +} \ No newline at end of file diff --git a/src/main/java/ru/ulstu/is/sbapp/jwt/JwtProvider.java b/src/main/java/ru/ulstu/is/sbapp/jwt/JwtProvider.java new file mode 100644 index 0000000..9504cb1 --- /dev/null +++ b/src/main/java/ru/ulstu/is/sbapp/jwt/JwtProvider.java @@ -0,0 +1,108 @@ +package ru.ulstu.is.sbapp.jwt; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.exceptions.JWTVerificationException; +import com.auth0.jwt.interfaces.DecodedJWT; +import com.auth0.jwt.interfaces.JWTVerifier; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.Date; +import java.util.Optional; +import java.util.UUID; + +@Component +public class JwtProvider { + private final static Logger LOG = LoggerFactory.getLogger(JwtProvider.class); + + private final static byte[] HEX_ARRAY = "0123456789ABCDEF".getBytes(StandardCharsets.US_ASCII); + private final static String ISSUER = "auth0"; + + private final Algorithm algorithm; + private final JWTVerifier verifier; + + public JwtProvider(JwtProperties jwtProperties) { + if (!jwtProperties.isDev()) { + LOG.info("Generate new JWT key for prod"); + try { + final MessageDigest salt = MessageDigest.getInstance("SHA-256"); + salt.update(UUID.randomUUID().toString().getBytes(StandardCharsets.UTF_8)); + LOG.info("Use generated JWT key for prod \n{}", bytesToHex(salt.digest())); + algorithm = Algorithm.HMAC256(bytesToHex(salt.digest())); + } catch (NoSuchAlgorithmException e) { + throw new JwtException(e); + } + } else { + LOG.info("Use default JWT key for dev \n{}", jwtProperties.getDevToken()); + algorithm = Algorithm.HMAC256(jwtProperties.getDevToken()); + } + verifier = JWT.require(algorithm) + .withIssuer(ISSUER) + .build(); + } + + private static String bytesToHex(byte[] bytes) { + byte[] hexChars = new byte[bytes.length * 2]; + for (int j = 0; j < bytes.length; j++) { + int v = bytes[j] & 0xFF; + hexChars[j * 2] = HEX_ARRAY[v >>> 4]; + hexChars[j * 2 + 1] = HEX_ARRAY[v & 0x0F]; + } + return new String(hexChars, StandardCharsets.UTF_8); + } + + public String generateToken(String login) { + final Date issueDate = Date.from(LocalDate.now() + .atStartOfDay(ZoneId.systemDefault()) + .toInstant()); + final Date expireDate = Date.from(LocalDate.now() + .plusDays(15) + .atStartOfDay(ZoneId.systemDefault()) + .toInstant()); + var temp = JWT.create(); + var temp2 = temp.withIssuer(ISSUER); + var temp3 = temp2.withIssuedAt(issueDate); + var temp4 = temp3.withExpiresAt(expireDate); + var temp5 = temp4.withSubject(login); + var temp6 = temp5.sign(algorithm); + return temp6; + } + + private DecodedJWT validateToken(String token) { + try { + return verifier.verify(token); + } catch (JWTVerificationException e) { + throw new JwtException(String.format("Token verification error: %s", e.getMessage())); + } + } + + public boolean isTokenValid(String token) { + if (!StringUtils.hasText(token)) { + return false; + } + try { + validateToken(token); + return true; + } catch (JwtException e) { + LOG.error(e.getMessage()); + return false; + } + } + + public Optional getLoginFromToken(String token) { + try { + return Optional.ofNullable(validateToken(token).getSubject()); + } catch (JwtException e) { + LOG.error(e.getMessage()); + return Optional.empty(); + } + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index ccc05e8..32dc422 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -8,4 +8,7 @@ spring.jpa.database-platform=org.hibernate.dialect.H2Dialect spring.jpa.hibernate.ddl-auto=update spring.h2.console.enabled=true spring.h2.console.settings.trace=false -spring.h2.console.settings.web-allow-others=false \ No newline at end of file +spring.h2.console.settings.web-allow-others=false +jwt.dev-token=my-secret-jwt +jwt.dev=true +jwt.secret = my-secret-jwt \ No newline at end of file