From 58274806e30a91b1ed9d2acbc88a879a37caaa47 Mon Sep 17 00:00:00 2001 From: Liosha Date: Sun, 13 Sep 2026 15:51:42 +0200 Subject: [PATCH] demo-decouple --- .../example/demo_decoupled/DTOs/RoleDto.java | 49 +++++ .../example/demo_decoupled/DTOs/UserDto.java | 103 +++++++++ .../controllers/AuthController.java | 131 +++++++++++ .../controllers/RolesController.java | 39 ++++ .../controllers/SettingsController.java | 40 ++++ .../controllers/TestController.java | 35 +++ .../controllers/TutorialController.java | 117 ++++++++++ .../controllers/UsersController.java | 39 ++++ .../initData/InitDataConfiguration.java | 20 ++ .../initData/InitDataService.java | 203 ++++++++++++++++++ .../example/demo_decoupled/model/ERole.java | 7 + .../example/demo_decoupled/model/Role.java | 41 ++++ .../example/demo_decoupled/model/Setting.java | 17 ++ .../demo_decoupled/model/Tutorial.java | 66 ++++++ .../example/demo_decoupled/model/User.java | 92 ++++++++ .../com/example/demo_decoupled/model/data.sql | 16 ++ .../payload/request/LoginRequest.java | 28 +++ .../payload/request/SignupRequest.java | 60 ++++++ .../payload/response/MessageResponse.java | 17 ++ .../payload/response/UserInfoResponse.java | 47 ++++ .../repository/RoleRepository.java | 16 ++ .../repository/SettingsRepository.java | 12 ++ .../repository/TutorialRepository.java | 16 ++ .../repository/UserRepository.java | 18 ++ .../security/WebSecurityConfig.java | 129 +++++++++++ .../security/jwt/AuthEntryPointJwt.java | 42 ++++ .../security/jwt/AuthTokenFilter.java | 60 ++++++ .../demo_decoupled/security/jwt/JwtUtils.java | 88 ++++++++ .../security/services/UserDetailsImpl.java | 104 +++++++++ .../services/UserDetailsServiceImpl.java | 29 +++ src/main/resources/application.yaml | 28 +++ src/main/resources/db/migration/V1__Init.sql | 81 +++++++ src/main/resources/initData/roles.json | 10 + src/main/resources/initData/users.json | 34 +++ 34 files changed, 1834 insertions(+) create mode 100644 src/main/java/com/example/demo_decoupled/DTOs/RoleDto.java create mode 100644 src/main/java/com/example/demo_decoupled/DTOs/UserDto.java create mode 100644 src/main/java/com/example/demo_decoupled/controllers/AuthController.java create mode 100644 src/main/java/com/example/demo_decoupled/controllers/RolesController.java create mode 100644 src/main/java/com/example/demo_decoupled/controllers/SettingsController.java create mode 100644 src/main/java/com/example/demo_decoupled/controllers/TestController.java create mode 100644 src/main/java/com/example/demo_decoupled/controllers/TutorialController.java create mode 100644 src/main/java/com/example/demo_decoupled/controllers/UsersController.java create mode 100644 src/main/java/com/example/demo_decoupled/initData/InitDataConfiguration.java create mode 100644 src/main/java/com/example/demo_decoupled/initData/InitDataService.java create mode 100644 src/main/java/com/example/demo_decoupled/model/ERole.java create mode 100644 src/main/java/com/example/demo_decoupled/model/Role.java create mode 100644 src/main/java/com/example/demo_decoupled/model/Setting.java create mode 100644 src/main/java/com/example/demo_decoupled/model/Tutorial.java create mode 100644 src/main/java/com/example/demo_decoupled/model/User.java create mode 100644 src/main/java/com/example/demo_decoupled/model/data.sql create mode 100644 src/main/java/com/example/demo_decoupled/payload/request/LoginRequest.java create mode 100644 src/main/java/com/example/demo_decoupled/payload/request/SignupRequest.java create mode 100644 src/main/java/com/example/demo_decoupled/payload/response/MessageResponse.java create mode 100644 src/main/java/com/example/demo_decoupled/payload/response/UserInfoResponse.java create mode 100644 src/main/java/com/example/demo_decoupled/repository/RoleRepository.java create mode 100644 src/main/java/com/example/demo_decoupled/repository/SettingsRepository.java create mode 100644 src/main/java/com/example/demo_decoupled/repository/TutorialRepository.java create mode 100644 src/main/java/com/example/demo_decoupled/repository/UserRepository.java create mode 100644 src/main/java/com/example/demo_decoupled/security/WebSecurityConfig.java create mode 100644 src/main/java/com/example/demo_decoupled/security/jwt/AuthEntryPointJwt.java create mode 100644 src/main/java/com/example/demo_decoupled/security/jwt/AuthTokenFilter.java create mode 100644 src/main/java/com/example/demo_decoupled/security/jwt/JwtUtils.java create mode 100644 src/main/java/com/example/demo_decoupled/security/services/UserDetailsImpl.java create mode 100644 src/main/java/com/example/demo_decoupled/security/services/UserDetailsServiceImpl.java create mode 100644 src/main/resources/application.yaml create mode 100644 src/main/resources/db/migration/V1__Init.sql create mode 100644 src/main/resources/initData/roles.json create mode 100644 src/main/resources/initData/users.json diff --git a/src/main/java/com/example/demo_decoupled/DTOs/RoleDto.java b/src/main/java/com/example/demo_decoupled/DTOs/RoleDto.java new file mode 100644 index 0000000..a674489 --- /dev/null +++ b/src/main/java/com/example/demo_decoupled/DTOs/RoleDto.java @@ -0,0 +1,49 @@ +package com.example.demo_decoupled.DTOs; + + +import com.example.demo_decoupled.model.ERole; +import com.example.demo_decoupled.model.Role; +import com.fasterxml.jackson.annotation.JsonIgnore; + +public class RoleDto { + @JsonIgnore + private Long id; + private ERole name; + + public RoleDto() { + } + + public RoleDto(Long id, ERole name) { + this.id = id; + this.name = name; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public ERole getName() { + return name; + } + + public void setName(ERole name) { + this.name = name; + } + + // Mapping helpers + public static RoleDto fromEntity(Role role) { + if (role == null) return null; + return new RoleDto(role.getId(), role.getName()); + } + + public Role toEntity() { + Role role = new Role(); + role.setId(this.id); + role.setName(this.name); + return role; + } +} diff --git a/src/main/java/com/example/demo_decoupled/DTOs/UserDto.java b/src/main/java/com/example/demo_decoupled/DTOs/UserDto.java new file mode 100644 index 0000000..4f1104c --- /dev/null +++ b/src/main/java/com/example/demo_decoupled/DTOs/UserDto.java @@ -0,0 +1,103 @@ +package com.example.demo_decoupled.DTOs; + + +import com.example.demo_decoupled.model.User; +import com.fasterxml.jackson.annotation.JsonIgnore; + +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +public class UserDto { + @JsonIgnore + private Long id; + private String username; + private String email; + + private String password; // typically you don't expose password in DTOs + + private Set roles; // typically you don't expose password in DTOs + + + public UserDto() { + } + + public UserDto(Long id, String username, String email, String password, Set roles) { + this.id = id; + this.username = username; + this.email = email; + this.password = password; + this.roles = roles; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public Set getRoles() { + return roles; + } + + public void setRoles(Set roles) { + this.roles = roles; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + // Mapping helpers + public static UserDto fromEntity(User user) { + if (user == null) return null; + return new UserDto( + user.getId(), + user.getUsername(), + user.getEmail(), + user.getPassword(), + user.getRoles() == null ? null : + user.getRoles().stream() + .filter(Objects::nonNull) + .map(RoleDto::fromEntity) + .collect(Collectors.toSet()) + ); + } + + public User toEntity() { + User user = new User(); + user.setId(this.id); + user.setUsername(this.username); + user.setEmail(this.email); + user.setPassword(this.password); // Assuming password is already hashed + if (this.roles != null) { + user.setRoles(this.roles.stream() + .filter(Objects::nonNull) + .map(RoleDto::toEntity) + .collect(Collectors.toSet())); + } + return user; + } +} diff --git a/src/main/java/com/example/demo_decoupled/controllers/AuthController.java b/src/main/java/com/example/demo_decoupled/controllers/AuthController.java new file mode 100644 index 0000000..d084611 --- /dev/null +++ b/src/main/java/com/example/demo_decoupled/controllers/AuthController.java @@ -0,0 +1,131 @@ +package com.example.demo_decoupled.controllers; +import com.example.demo_decoupled.model.ERole; +import com.example.demo_decoupled.model.Role; +import com.example.demo_decoupled.model.User; +import com.example.demo_decoupled.payload.request.LoginRequest; +import com.example.demo_decoupled.payload.request.SignupRequest; +import com.example.demo_decoupled.payload.response.MessageResponse; +import com.example.demo_decoupled.payload.response.UserInfoResponse; +import com.example.demo_decoupled.repository.RoleRepository; +import com.example.demo_decoupled.repository.UserRepository; +import com.example.demo_decoupled.security.jwt.JwtUtils; +import com.example.demo_decoupled.security.services.UserDetailsImpl; +import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.bind.annotation.*; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +//@CrossOrigin(origins = "*", maxAge = 3600) +@CrossOrigin(origins = "http://localhost:4200", maxAge = 3600, allowCredentials="true") +@RestController +@RequestMapping("/api/auth") +public class AuthController { + @Autowired + AuthenticationManager authenticationManager; + + @Autowired + private UserRepository userRepository; + + @Autowired + RoleRepository roleRepository; + + @Autowired + PasswordEncoder encoder; + + @Autowired + JwtUtils jwtUtils; + + @PostMapping("/signin") + public ResponseEntity authenticateUser(@Valid @RequestBody LoginRequest loginRequest) { + + Authentication authentication = authenticationManager + .authenticate(new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword())); + + SecurityContextHolder.getContext().setAuthentication(authentication); + + UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal(); + + ResponseCookie jwtCookie = jwtUtils.generateJwtCookie(userDetails); + + List roles = userDetails.getAuthorities().stream() + .map(item -> item.getAuthority()) + .collect(Collectors.toList()); + + return ResponseEntity.ok().header(HttpHeaders.SET_COOKIE, jwtCookie.toString()) + .body(new UserInfoResponse( + userDetails.getId(), + userDetails.getUsername(), + userDetails.getEmail(), + roles)); + } + + @PostMapping("/signup") + public ResponseEntity registerUser(@Valid @RequestBody SignupRequest signUpRequest) { + if (userRepository.existsByUsername(signUpRequest.getUsername())) { + return ResponseEntity.badRequest().body(new MessageResponse("Error: Username is already taken!")); + } + + if (userRepository.existsByEmail(signUpRequest.getEmail())) { + return ResponseEntity.badRequest().body(new MessageResponse("Error: Email is already in use!")); + } + + // Create new user's account + User user = new User(signUpRequest.getUsername(), + signUpRequest.getEmail(), + encoder.encode(signUpRequest.getPassword())); + + Set strRoles = signUpRequest.getRole(); + Set roles = new HashSet<>(); + + if (strRoles == null) { + Role userRole = roleRepository.findByName(ERole.ROLE_USER) + .orElseThrow(() -> new RuntimeException("Error: Role is not found.")); + roles.add(userRole); + } else { + strRoles.forEach(role -> { + switch (role) { + case "admin": + Role adminRole = roleRepository.findByName(ERole.ROLE_ADMIN) + .orElseThrow(() -> new RuntimeException("Error: Role is not found.")); + roles.add(adminRole); + + break; + case "mod": + Role modRole = roleRepository.findByName(ERole.ROLE_MODERATOR) + .orElseThrow(() -> new RuntimeException("Error: Role is not found.")); + roles.add(modRole); + + break; + default: + Role userRole = roleRepository.findByName(ERole.ROLE_USER) + .orElseThrow(() -> new RuntimeException("Error: Role is not found.")); + roles.add(userRole); + } + }); + } + + user.setRoles(roles); + userRepository.save(user); + + return ResponseEntity.ok(new MessageResponse("User registered successfully!")); + } + + @PostMapping("/signout") + public ResponseEntity logoutUser() { + ResponseCookie cookie = jwtUtils.getCleanJwtCookie(); + return ResponseEntity.ok().header(HttpHeaders.SET_COOKIE, cookie.toString()) + .body(new MessageResponse("You've been signed out!")); + } +} diff --git a/src/main/java/com/example/demo_decoupled/controllers/RolesController.java b/src/main/java/com/example/demo_decoupled/controllers/RolesController.java new file mode 100644 index 0000000..4881ead --- /dev/null +++ b/src/main/java/com/example/demo_decoupled/controllers/RolesController.java @@ -0,0 +1,39 @@ +package com.example.demo_decoupled.controllers; + + + +import com.example.demo_decoupled.model.Role; +import com.example.demo_decoupled.repository.RoleRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.ArrayList; +import java.util.List; + +@CrossOrigin(origins = "${app.origin}", maxAge = 3600, allowCredentials="true") +@RestController +@RequestMapping("/api") +public class RolesController { + @Autowired + private RoleRepository roleRepository; + + @GetMapping("/roles") + public ResponseEntity> getRoles(@RequestParam(required = false) String title) { + try { + List role = new ArrayList(); + role = roleRepository.findAll(); + //userRepository.findAll().forEach(users::add); + + + if (role.isEmpty()) { + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + return new ResponseEntity<>(role, HttpStatus.OK); + } catch (Exception e) { + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } +} diff --git a/src/main/java/com/example/demo_decoupled/controllers/SettingsController.java b/src/main/java/com/example/demo_decoupled/controllers/SettingsController.java new file mode 100644 index 0000000..9c5b6be --- /dev/null +++ b/src/main/java/com/example/demo_decoupled/controllers/SettingsController.java @@ -0,0 +1,40 @@ +package com.example.demo_decoupled.controllers; + + + +import com.example.demo_decoupled.model.Setting; +import com.example.demo_decoupled.repository.SettingsRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api") +public class SettingsController { + @Autowired + private SettingsRepository settingsRepository; + + @GetMapping("/settings") + public ResponseEntity> getSettings(@RequestParam(required = false) String title) { + try { + List settings = settingsRepository.findAll(); + //userRepository.findAll().forEach(users::add); + + + if (settings.isEmpty()) { + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + return new ResponseEntity<>(settings, HttpStatus.OK); + } catch (Exception e) { + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + +} diff --git a/src/main/java/com/example/demo_decoupled/controllers/TestController.java b/src/main/java/com/example/demo_decoupled/controllers/TestController.java new file mode 100644 index 0000000..a2273f4 --- /dev/null +++ b/src/main/java/com/example/demo_decoupled/controllers/TestController.java @@ -0,0 +1,35 @@ +package com.example.demo_decoupled.controllers; + +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@CrossOrigin(origins = "*", maxAge = 3600) +@RestController +@RequestMapping("/api/test") +public class TestController { + @GetMapping("/all") + public String allAccess() { + return "Public Content."; + } + + @GetMapping("/user") + @PreAuthorize("hasRole('USER') or hasRole('MODERATOR') or hasRole('ADMIN')") + public String userAccess() { + return "User Content."; + } + + @GetMapping("/mod") + @PreAuthorize("hasRole('MODERATOR')") + public String moderatorAccess() { + return "Moderator Board."; + } + + @GetMapping("/admin") + @PreAuthorize("hasRole('ADMIN')") + public String adminAccess() { + return "Admin Board."; + } +} diff --git a/src/main/java/com/example/demo_decoupled/controllers/TutorialController.java b/src/main/java/com/example/demo_decoupled/controllers/TutorialController.java new file mode 100644 index 0000000..a427de8 --- /dev/null +++ b/src/main/java/com/example/demo_decoupled/controllers/TutorialController.java @@ -0,0 +1,117 @@ +package com.example.demo_decoupled.controllers; + + + +import com.example.demo_decoupled.model.Tutorial; +import com.example.demo_decoupled.repository.TutorialRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + + +@CrossOrigin(origins = "${app.origin}", maxAge = 3600, allowCredentials="true") +@RestController +@RequestMapping("/api") +public class TutorialController { + + @Autowired + TutorialRepository tutorialRepository; + + @GetMapping("/tutorials") + public ResponseEntity> getAllTutorials(@RequestParam(required = false) String title) { + try { + List tutorials = new ArrayList(); + + if (title == null) + tutorialRepository.findAll().forEach(tutorials::add); + else + tutorialRepository.findByTitleContaining(title).forEach(tutorials::add); + + if (tutorials.isEmpty()) { + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + return new ResponseEntity<>(tutorials, HttpStatus.OK); + } catch (Exception e) { + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @GetMapping("/tutorials/{id}") + public ResponseEntity getTutorialById(@PathVariable("id") long id) { + Optional tutorialData = tutorialRepository.findById(id); + + if (tutorialData.isPresent()) { + return new ResponseEntity<>(tutorialData.get(), HttpStatus.OK); + } else { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + } + + @PostMapping("/tutorials") + public ResponseEntity createTutorial(@RequestBody Tutorial tutorial) { + try { + Tutorial _tutorial = tutorialRepository + .save(new Tutorial(tutorial.getTitle(), tutorial.getDescription(), false)); + return new ResponseEntity<>(_tutorial, HttpStatus.CREATED); + } catch (Exception e) { + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @PutMapping("/tutorials/{id}") + public ResponseEntity updateTutorial(@PathVariable("id") long id, @RequestBody Tutorial tutorial) { + Optional tutorialData = tutorialRepository.findById(id); + + if (tutorialData.isPresent()) { + Tutorial _tutorial = tutorialData.get(); + _tutorial.setTitle(tutorial.getTitle()); + _tutorial.setDescription(tutorial.getDescription()); + _tutorial.setPublished(tutorial.isPublished()); + return new ResponseEntity<>(tutorialRepository.save(_tutorial), HttpStatus.OK); + } else { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + } + + @DeleteMapping("/tutorials/{id}") + public ResponseEntity deleteTutorial(@PathVariable("id") long id) { + try { + tutorialRepository.deleteById(id); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } catch (Exception e) { + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @DeleteMapping("/tutorials") + public ResponseEntity deleteAllTutorials() { + try { + tutorialRepository.deleteAll(); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } catch (Exception e) { + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + + } + + @GetMapping("/tutorials/published") + public ResponseEntity> findByPublished() { + try { + List tutorials = tutorialRepository.findByPublished(true); + + if (tutorials.isEmpty()) { + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + return new ResponseEntity<>(tutorials, HttpStatus.OK); + } catch (Exception e) { + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + +} \ No newline at end of file diff --git a/src/main/java/com/example/demo_decoupled/controllers/UsersController.java b/src/main/java/com/example/demo_decoupled/controllers/UsersController.java new file mode 100644 index 0000000..04f1be6 --- /dev/null +++ b/src/main/java/com/example/demo_decoupled/controllers/UsersController.java @@ -0,0 +1,39 @@ +package com.example.demo_decoupled.controllers; + + + +import com.example.demo_decoupled.model.User; +import com.example.demo_decoupled.repository.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.ArrayList; +import java.util.List; + +@CrossOrigin(origins = "${app.origin}", maxAge = 3600, allowCredentials="true") +@RestController +@RequestMapping("/api") +public class UsersController { + @Autowired + private UserRepository userRepository; + + @GetMapping("/users") + public ResponseEntity> getUsers(@RequestParam(required = false) String title) { + try { + List users = new ArrayList(); + users = userRepository.findAll(); + //userRepository.findAll().forEach(users::add); + + + if (users.isEmpty()) { + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + return new ResponseEntity<>(users, HttpStatus.OK); + } catch (Exception e) { + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } +} diff --git a/src/main/java/com/example/demo_decoupled/initData/InitDataConfiguration.java b/src/main/java/com/example/demo_decoupled/initData/InitDataConfiguration.java new file mode 100644 index 0000000..5c7e6b0 --- /dev/null +++ b/src/main/java/com/example/demo_decoupled/initData/InitDataConfiguration.java @@ -0,0 +1,20 @@ +package com.example.demo_decoupled.initData; + +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; + +@Configuration +//@ConditionalOnProperty(name = "app.init.data.enabled", havingValue = "true", matchIfMissing = true) +public class InitDataConfiguration { + + @Autowired + private InitDataService _initDataService; + + @PostConstruct + public void init() { + + _initDataService.initData(); + } + +} diff --git a/src/main/java/com/example/demo_decoupled/initData/InitDataService.java b/src/main/java/com/example/demo_decoupled/initData/InitDataService.java new file mode 100644 index 0000000..606bee9 --- /dev/null +++ b/src/main/java/com/example/demo_decoupled/initData/InitDataService.java @@ -0,0 +1,203 @@ +package com.example.demo_decoupled.initData; + + +import com.example.demo_decoupled.DTOs.RoleDto; +import com.example.demo_decoupled.DTOs.UserDto; +import com.example.demo_decoupled.model.ERole; +import com.example.demo_decoupled.model.Role; +import com.example.demo_decoupled.model.User; +import com.example.demo_decoupled.repository.RoleRepository; +import com.example.demo_decoupled.repository.UserRepository; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Primary; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.ResourceLoader; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@Primary +public class InitDataService { + + private static final Logger _logger = LoggerFactory.getLogger(InitDataService.class); + + protected ObjectMapper _mapper; + + @Autowired + private ResourceLoader _resources; + @Autowired + private RoleRepository _roleRepository; + + @Autowired + private UserRepository _userRepository; + + + private final Path _initDataPath = Path.of("/jambotron_data/initData/"); + + public InitDataService(){ + + _mapper = new ObjectMapper(); + + + } + + private void extractInitData(){ + _logger.info("Extracting init data to {}", _initDataPath.toAbsolutePath()); + if(!_initDataPath.toFile().exists()) { + _logger.info("Creating init data directory at {}", _initDataPath.toAbsolutePath()); + try { + Files.createDirectories(_initDataPath); + } catch (Exception e) { + _logger.error("Failed to create init data directory", e); + } + } + extractJsonFile("roles.json"); + extractJsonFile("users.json"); + } + + private void extractJsonFile(String fileName) { + try { + ClassPathResource resource = new ClassPathResource("initData/"+ fileName); + + + Path targetPath = _initDataPath.resolve(fileName); + if (!Files.exists(targetPath)) { + try (InputStream inputStream = resource.getInputStream()) { + Files.copy(resource.getInputStream(), targetPath); + } + + _logger.info("Copied {} to {}", fileName, targetPath.toAbsolutePath()); + } else { + try (InputStream inputStream = resource.getInputStream()) { + Files.copy(resource.getInputStream(), targetPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + + _logger.warn("{} already exists, REPLACE", targetPath.toAbsolutePath()); + } + + } catch (Exception e) { + _logger.error("Failed to extract JSON file: {}", fileName, e); + } + } + + @Transactional + public void importRoles(Path jsonFile) throws Exception { + _logger.info("Importing roles from {}", jsonFile.toAbsolutePath()); + List items = readList(jsonFile, new TypeReference>() {}); + for (RoleDto dto : items) { + Role roleEntity = dto.toEntity(); + // If a role with same name exists, reuse its ID to upsert +// _roleRepository.findByName(entity.getName()) +// .ifPresent(existing -> entity.setId(existing.getId())); + + if(!_roleRepository.existsByName(roleEntity.getName())) { + _roleRepository.save(roleEntity); + _logger.info("Saving new role: {}", roleEntity.getName()); + } else { + _logger.info("Role {} already exists, SKIPPING import", roleEntity.getName()); + } + } + _logger.info("Imported {} roles", items.stream() + .map(RoleDto::getName).toList() + .stream().map(ERole::toString).collect(Collectors.joining(", "))); + } + + @Transactional + public void importUsers(Path jsonFile) throws Exception { + _logger.info("Importing users from {}", jsonFile.toAbsolutePath()); + // Preload roles map by name for quick lookup + Map rolesByName = _roleRepository.findAll().stream() + .collect(Collectors.toMap(Role::getName, r -> r)); + + List items = readList(jsonFile, new TypeReference>() {}); + for (UserDto dto : items) { + if(!_userRepository.existsByUsername(dto.getUsername())) { + + User user = new User(); + if (dto.getId() != null) user.setId(dto.getId()); + user.setUsername(dto.getUsername()); + user.setEmail(dto.getEmail()); + // Expecting already-hashed values; do not import plain text secrets + user.setPassword(dto.getPassword()); + + // Resolve roles by enum name + Set roles = new HashSet<>(); + if (dto.getRoles() != null) { + for (RoleDto roleDto : dto.getRoles()) { + ERole erole = ERole.valueOf(roleDto.getName().name()); + Role role = rolesByName.get(erole); + if (role == null) { + // Optionally create missing roles on the fly + role = new Role(); + role.setName(erole); + role = _roleRepository.save(role); + rolesByName.put(erole, role); + } + roles.add(role); + } + } + user.setRoles(roles); + + _userRepository.save(user); + + _logger.info("Imported user: {} with roles: {}", user.getUsername(), user.getRoles().stream() + .map(Role::getName).collect(Collectors.toList()).stream().map(ERole::toString) + .collect(Collectors.joining(", "))); + } else { + _logger.warn("User with username {} already exists, SKIPPING import", dto.getUsername()); + + } + } + } + + protected List readList(Path file, TypeReference> type) throws Exception { + String json = Files.readString(file); + return _mapper.readValue(json, type); + } + + public void initData() { + + extractInitData(); + + _logger.info("Initializing data import from {}", _initDataPath.toAbsolutePath()); + + try { + + Path rolesFile = _initDataPath.resolve("roles.json"); + + if (rolesFile.toFile().exists()) { + + importRoles(rolesFile); + } else { + + _logger.warn("Roles import file not found: {}", rolesFile.getFileName()); + } + + Path usersFile = _initDataPath.resolve("users.json"); + + if (usersFile.toFile().exists()) { + importUsers(usersFile); + } else { + + _logger.warn("Users import file not found: {}", usersFile.getFileName()); + } + } catch (Exception e) { + _logger.error("Error importing data", e); + throw new RuntimeException("Failed to import data", e); + } + } +} diff --git a/src/main/java/com/example/demo_decoupled/model/ERole.java b/src/main/java/com/example/demo_decoupled/model/ERole.java new file mode 100644 index 0000000..d432bbe --- /dev/null +++ b/src/main/java/com/example/demo_decoupled/model/ERole.java @@ -0,0 +1,7 @@ +package com.example.demo_decoupled.model; + +public enum ERole { + ROLE_USER, + ROLE_MODERATOR, + ROLE_ADMIN +} diff --git a/src/main/java/com/example/demo_decoupled/model/Role.java b/src/main/java/com/example/demo_decoupled/model/Role.java new file mode 100644 index 0000000..aa8d39d --- /dev/null +++ b/src/main/java/com/example/demo_decoupled/model/Role.java @@ -0,0 +1,41 @@ +package com.example.demo_decoupled.model; + +import jakarta.persistence.*; + + + +@Entity +@Table(name = "roles") +public class Role { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(length = 20) + private ERole name; + + public Role() { + + } + + public Role(ERole name) { + this.name = name; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public ERole getName() { + return name; + } + + public void setName(ERole name) { + this.name = name; + } +} diff --git a/src/main/java/com/example/demo_decoupled/model/Setting.java b/src/main/java/com/example/demo_decoupled/model/Setting.java new file mode 100644 index 0000000..7951dcb --- /dev/null +++ b/src/main/java/com/example/demo_decoupled/model/Setting.java @@ -0,0 +1,17 @@ +package com.example.demo_decoupled.model; + + +import jakarta.persistence.*; + +@Entity +@Table(name = "settings") +public class Setting { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private long id; + + @Column(name = "useremaileUniq") + private Boolean useremaileUniq; + +} diff --git a/src/main/java/com/example/demo_decoupled/model/Tutorial.java b/src/main/java/com/example/demo_decoupled/model/Tutorial.java new file mode 100644 index 0000000..8566bdd --- /dev/null +++ b/src/main/java/com/example/demo_decoupled/model/Tutorial.java @@ -0,0 +1,66 @@ +package com.example.demo_decoupled.model; + +import jakarta.persistence.*; + + + +@Entity +@Table(name = "tutorials") +public class Tutorial { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private long id; + + @Column(name = "title") + private String title; + + @Column(name = "description") + private String description; + + @Column(name = "published") + private boolean published; + + public Tutorial() { + + } + + public Tutorial(String title, String description, boolean published) { + this.title = title; + this.description = description; + this.published = published; + } + + public long getId() { + return id; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public boolean isPublished() { + return published; + } + + public void setPublished(boolean isPublished) { + this.published = isPublished; + } + + @Override + public String toString() { + return "Tutorial [id=" + id + ", title=" + title + ", desc=" + description + ", published=" + published + "]"; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/demo_decoupled/model/User.java b/src/main/java/com/example/demo_decoupled/model/User.java new file mode 100644 index 0000000..23802d1 --- /dev/null +++ b/src/main/java/com/example/demo_decoupled/model/User.java @@ -0,0 +1,92 @@ +package com.example.demo_decoupled.model; + +import jakarta.persistence.*; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import java.util.HashSet; +import java.util.Set; + + + +@Entity +@Table( name = "users", + uniqueConstraints = { + @UniqueConstraint(columnNames = "username"), + @UniqueConstraint(columnNames = "email") + }) +public class User { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private long id; + + @Column(name = "username") + @NotBlank + @Size(max = 20) + private String username; + + @NotBlank + @Size(max = 50) + @Email + private String email; + + @NotBlank + @Size(max = 120) + private String password; + + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable( name = "user_roles", + joinColumns = @JoinColumn(name = "user_id"), + inverseJoinColumns = @JoinColumn(name = "role_id")) + private Set roles = new HashSet<>(); + + public User() { + } + + public User(String username, String email, String password) { + this.username = username; + this.email = email; + this.password = password; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public Set getRoles() { + return roles; + } + + public void setRoles(Set roles) { + this.roles = roles; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/demo_decoupled/model/data.sql b/src/main/java/com/example/demo_decoupled/model/data.sql new file mode 100644 index 0000000..bb60ff0 --- /dev/null +++ b/src/main/java/com/example/demo_decoupled/model/data.sql @@ -0,0 +1,16 @@ + +INSERT INTO roles (id, name) VALUES (1, 'ROLE_USER'); +INSERT INTO roles (id, name) VALUES (2, 'ROLE_MODERATOR'); +INSERT INTO roles (id, name) VALUES (3, 'ROLE_ADMIN'); + +INSERT INTO users (id, email, password, username) +VALUES (1, ''liosha84@gmail.com'', ''$2a$10$qyoKXYSukha6XCjorTzoweF4Os1pwmwyzbaSsb3RCVB0LK6WLQKPC'', ''Admin''); +INSERT INTO users (id, email, password, username) +VALUES (4, ''Moderator@gmail.com'', ''$2a$10$zLo5th8Xbfq.MM7y/dCRQu3Ud7HHyAwm.7.yS08ytJtkHMKrbOJlu'', ''Moderator''); +INSERT INTO users (id, email, password, username) +VALUES (5, ''user@gmail.com'', ''$2a$10$2EcwYffteBF3GhVaf5qPB.I7XiHepDEauU5D4fx9fpBXMTZI/QnnC'', ''User''); + + +INSERT INTO user_roles (user_id, role_id) VALUES (5, 1); +INSERT INTO user_roles (user_id, role_id) VALUES (1, 3); +INSERT INTO user_roles (user_id, role_id) VALUES (4, 2); \ No newline at end of file diff --git a/src/main/java/com/example/demo_decoupled/payload/request/LoginRequest.java b/src/main/java/com/example/demo_decoupled/payload/request/LoginRequest.java new file mode 100644 index 0000000..6be3421 --- /dev/null +++ b/src/main/java/com/example/demo_decoupled/payload/request/LoginRequest.java @@ -0,0 +1,28 @@ +package com.example.demo_decoupled.payload.request; + + +import jakarta.validation.constraints.NotBlank; + +public class LoginRequest { + @NotBlank + private String username; + + @NotBlank + private String password; + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/demo_decoupled/payload/request/SignupRequest.java b/src/main/java/com/example/demo_decoupled/payload/request/SignupRequest.java new file mode 100644 index 0000000..5a98d3a --- /dev/null +++ b/src/main/java/com/example/demo_decoupled/payload/request/SignupRequest.java @@ -0,0 +1,60 @@ +package com.example.demo_decoupled.payload.request; + + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import java.util.Set; + + + +public class SignupRequest { + @NotBlank + @Size(min = 3, max = 20) + private String username; + + @NotBlank + @Size(max = 50) + @Email + private String email; + + private Set role; + + @NotBlank + @Size(min = 6, max = 40) + private String password; + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public Set getRole() { + return this.role; + } + + public void setRole(Set role) { + this.role = role; + } +} + diff --git a/src/main/java/com/example/demo_decoupled/payload/response/MessageResponse.java b/src/main/java/com/example/demo_decoupled/payload/response/MessageResponse.java new file mode 100644 index 0000000..bc76134 --- /dev/null +++ b/src/main/java/com/example/demo_decoupled/payload/response/MessageResponse.java @@ -0,0 +1,17 @@ +package com.example.demo_decoupled.payload.response; + +public class MessageResponse { + private String message; + + public MessageResponse(String message) { + this.message = message; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } +} diff --git a/src/main/java/com/example/demo_decoupled/payload/response/UserInfoResponse.java b/src/main/java/com/example/demo_decoupled/payload/response/UserInfoResponse.java new file mode 100644 index 0000000..768dc8a --- /dev/null +++ b/src/main/java/com/example/demo_decoupled/payload/response/UserInfoResponse.java @@ -0,0 +1,47 @@ +package com.example.demo_decoupled.payload.response; + + +import java.util.List; + +public class UserInfoResponse { + + private Long id; + private String username; + private String email; + private List roles; + + public UserInfoResponse(Long id, String username, String email, List roles) { + this.id = id; + this.username = username; + this.email = email; + this.roles = roles; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public List getRoles() { + return roles; + } +} diff --git a/src/main/java/com/example/demo_decoupled/repository/RoleRepository.java b/src/main/java/com/example/demo_decoupled/repository/RoleRepository.java new file mode 100644 index 0000000..a6b86be --- /dev/null +++ b/src/main/java/com/example/demo_decoupled/repository/RoleRepository.java @@ -0,0 +1,16 @@ +package com.example.demo_decoupled.repository; + + + +import com.example.demo_decoupled.model.ERole; +import com.example.demo_decoupled.model.Role; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface RoleRepository extends JpaRepository { + Optional findByName(ERole name); + boolean existsByName(ERole name); +} diff --git a/src/main/java/com/example/demo_decoupled/repository/SettingsRepository.java b/src/main/java/com/example/demo_decoupled/repository/SettingsRepository.java new file mode 100644 index 0000000..57b20db --- /dev/null +++ b/src/main/java/com/example/demo_decoupled/repository/SettingsRepository.java @@ -0,0 +1,12 @@ +package com.example.demo_decoupled.repository; + + + +import com.example.demo_decoupled.model.Setting; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface SettingsRepository extends JpaRepository { + +} diff --git a/src/main/java/com/example/demo_decoupled/repository/TutorialRepository.java b/src/main/java/com/example/demo_decoupled/repository/TutorialRepository.java new file mode 100644 index 0000000..5e30ed3 --- /dev/null +++ b/src/main/java/com/example/demo_decoupled/repository/TutorialRepository.java @@ -0,0 +1,16 @@ +package com.example.demo_decoupled.repository; + + + +import com.example.demo_decoupled.model.Tutorial; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + + +@Repository +public interface TutorialRepository extends JpaRepository { + List findByPublished(boolean published); + List findByTitleContaining(String title); +} \ No newline at end of file diff --git a/src/main/java/com/example/demo_decoupled/repository/UserRepository.java b/src/main/java/com/example/demo_decoupled/repository/UserRepository.java new file mode 100644 index 0000000..cff1add --- /dev/null +++ b/src/main/java/com/example/demo_decoupled/repository/UserRepository.java @@ -0,0 +1,18 @@ +package com.example.demo_decoupled.repository; + + + +import com.example.demo_decoupled.model.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + Optional findByUsername(String username); + + Boolean existsByUsername(String username); + + Boolean existsByEmail(String email); +} diff --git a/src/main/java/com/example/demo_decoupled/security/WebSecurityConfig.java b/src/main/java/com/example/demo_decoupled/security/WebSecurityConfig.java new file mode 100644 index 0000000..d5040fc --- /dev/null +++ b/src/main/java/com/example/demo_decoupled/security/WebSecurityConfig.java @@ -0,0 +1,129 @@ +package com.example.demo_decoupled.security; + +import com.example.demo_decoupled.security.jwt.AuthEntryPointJwt; +import com.example.demo_decoupled.security.jwt.AuthTokenFilter; +import com.example.demo_decoupled.security.services.UserDetailsServiceImpl; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +//import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; + +@Configuration +@EnableMethodSecurity +//@EnableWebSecurity +//@EnableGlobalMethodSecurity( + // securedEnabled = true, + // jsr250Enabled = true, + //prePostEnabled = true) +public class WebSecurityConfig {// extends WebSecurityConfigurerAdapter { + @Autowired + UserDetailsServiceImpl userDetailsService; + + @Autowired + private AuthEntryPointJwt unauthorizedHandler; + + @Bean + public AuthTokenFilter authenticationJwtTokenFilter() { + return new AuthTokenFilter(); + } + +// @Override +// public void configure(AuthenticationManagerBuilder authenticationManagerBuilder) throws Exception { +// authenticationManagerBuilder.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder()); +// } + + @Bean + public DaoAuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(userDetailsService); + + //authProvider.setUserDetailsService(userDetailsService); + authProvider.setPasswordEncoder(passwordEncoder()); + + return authProvider; + } + +// @Bean +// @Override +// public AuthenticationManager authenticationManagerBean() throws Exception { +// return super.authenticationManagerBean(); +// } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception { + return authConfig.getAuthenticationManager(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + +// @Override +// protected void configure(HttpSecurity http) throws Exception { +// http +// .csrf() +// .disable() +// .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and() +// .authorizeRequests() +// +// +// // //Доступ только для пользователей с ролью Администратор +// //.antMatchers("/api/users").hasRole("ADMIN") +// // .antMatchers("/news").hasRole("USER") +// //Доступ разрешен всем пользователей +// .antMatchers("/*", "/home", "/resources/**").permitAll() +// .antMatchers("/api/auth/**").permitAll() +// .antMatchers("/api/tutorials").permitAll() +// .antMatchers("/api/tutorials/**").permitAll() +// //.antMatchers("/api/test/**").permitAll() +// //Все остальные страницы требуют аутентификации +// .anyRequest().authenticated() +// .and() +// .sessionManagement() +// .sessionCreationPolicy(SessionCreationPolicy.STATELESS) +// .and() +// .addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class); +// +// +// //http.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class); +// } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.csrf(csrf -> csrf.disable()) + .exceptionHandling(exception -> exception.authenticationEntryPoint(unauthorizedHandler)) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> + auth.requestMatchers("/api/auth/**").permitAll() + .requestMatchers("/*", "/home", "/resources/**").permitAll() + .requestMatchers("/api/test/**").permitAll() + .requestMatchers("/api/tutorials").permitAll() + + .requestMatchers("/api/users").permitAll() + .requestMatchers("/api/users/**").permitAll() + + .requestMatchers("/api/roles").permitAll() + .requestMatchers("/api/roles/**").permitAll() + + .requestMatchers("/api/tutorials/**").permitAll() + .anyRequest().authenticated() + ); + + http.authenticationProvider(authenticationProvider()); + + http.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} diff --git a/src/main/java/com/example/demo_decoupled/security/jwt/AuthEntryPointJwt.java b/src/main/java/com/example/demo_decoupled/security/jwt/AuthEntryPointJwt.java new file mode 100644 index 0000000..61537a3 --- /dev/null +++ b/src/main/java/com/example/demo_decoupled/security/jwt/AuthEntryPointJwt.java @@ -0,0 +1,42 @@ +package com.example.demo_decoupled.security.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + + +@Component +public class AuthEntryPointJwt implements AuthenticationEntryPoint { + + private static final Logger logger = LoggerFactory.getLogger(AuthEntryPointJwt.class); + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) + throws IOException, ServletException { + logger.error("Unauthorized error: {}", authException.getMessage()); + + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + + final Map body = new HashMap<>(); + body.put("status", HttpServletResponse.SC_UNAUTHORIZED); + body.put("error", "Unauthorized"); + body.put("message", authException.getMessage()); + body.put("path", request.getServletPath()); + + final ObjectMapper mapper = new ObjectMapper(); + mapper.writeValue(response.getOutputStream(), body); + } + +} diff --git a/src/main/java/com/example/demo_decoupled/security/jwt/AuthTokenFilter.java b/src/main/java/com/example/demo_decoupled/security/jwt/AuthTokenFilter.java new file mode 100644 index 0000000..974fedd --- /dev/null +++ b/src/main/java/com/example/demo_decoupled/security/jwt/AuthTokenFilter.java @@ -0,0 +1,60 @@ +package com.example.demo_decoupled.security.jwt; + + +import com.example.demo_decoupled.security.services.UserDetailsServiceImpl; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + + +public class AuthTokenFilter extends OncePerRequestFilter { + @Autowired + private JwtUtils jwtUtils; + + @Autowired + private UserDetailsServiceImpl userDetailsService; + + private static final Logger logger = LoggerFactory.getLogger(AuthTokenFilter.class); + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + try { + String jwt = parseJwt(request); + if (jwt != null && jwtUtils.validateJwtToken(jwt)) { + String username = jwtUtils.getUserNameFromJwtToken(jwt); + + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userDetails, + null, + userDetails.getAuthorities()); + + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (Exception e) { + logger.error("Cannot set user authentication: {}", e); + } + + filterChain.doFilter(request, response); + } + + private String parseJwt(HttpServletRequest request) { + String jwt = jwtUtils.getJwtFromCookies(request); + return jwt; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/demo_decoupled/security/jwt/JwtUtils.java b/src/main/java/com/example/demo_decoupled/security/jwt/JwtUtils.java new file mode 100644 index 0000000..b999e00 --- /dev/null +++ b/src/main/java/com/example/demo_decoupled/security/jwt/JwtUtils.java @@ -0,0 +1,88 @@ +package com.example.demo_decoupled.security.jwt; + + +import com.example.demo_decoupled.security.services.UserDetailsImpl; +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; +import org.springframework.web.util.WebUtils; + +import java.security.Key; +import java.util.Date; + +@Component +public class JwtUtils { + private static final Logger logger = LoggerFactory.getLogger(JwtUtils.class); + + @Value("${app.jwtSecret}") + private String jwtSecret; + + @Value("${app.jwtExpirationMs}") + private int jwtExpirationMs; + + @Value("${app.jwtCookieName}") + private String jwtCookie; + + public String getJwtFromCookies(HttpServletRequest request) { + Cookie cookie = WebUtils.getCookie(request, jwtCookie); + if (cookie != null) { + return cookie.getValue(); + } else { + return null; + } + } + + public ResponseCookie generateJwtCookie(UserDetailsImpl userPrincipal) { + String jwt = generateTokenFromUsername(userPrincipal.getUsername()); + ResponseCookie cookie = ResponseCookie.from(jwtCookie, jwt).path("/api").maxAge(24 * 60 * 60).httpOnly(true).build(); + return cookie; + } + + public ResponseCookie getCleanJwtCookie() { + ResponseCookie cookie = ResponseCookie.from(jwtCookie, null).path("/api").build(); + return cookie; + } + + public String getUserNameFromJwtToken(String token) { + return Jwts.parserBuilder().setSigningKey(key()).build() + .parseClaimsJws(token).getBody().getSubject(); + } + + private Key key() { + return Keys.hmacShaKeyFor(Decoders.BASE64.decode(jwtSecret));//java.util.Base64.getDecoder().decode(jwtSecret.getBytes())); + //Decoders.BASE64.decode(jwtSecret)); + } + + public boolean validateJwtToken(String authToken) { + try { + Jwts.parserBuilder().setSigningKey(key()).build().parse(authToken); + return true; + } catch (MalformedJwtException e) { + logger.error("Invalid JWT token: {}", e.getMessage()); + } catch (ExpiredJwtException e) { + logger.error("JWT token is expired: {}", e.getMessage()); + } catch (UnsupportedJwtException e) { + logger.error("JWT token is unsupported: {}", e.getMessage()); + } catch (IllegalArgumentException e) { + logger.error("JWT claims string is empty: {}", e.getMessage()); + } + + return false; + } + + public String generateTokenFromUsername(String username) { + return Jwts.builder() + .setSubject(username) + .setIssuedAt(new Date()) + .setExpiration(new Date((new Date()).getTime() + jwtExpirationMs)) + .signWith(key(), SignatureAlgorithm.HS256) + .compact(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/demo_decoupled/security/services/UserDetailsImpl.java b/src/main/java/com/example/demo_decoupled/security/services/UserDetailsImpl.java new file mode 100644 index 0000000..2a7e7c3 --- /dev/null +++ b/src/main/java/com/example/demo_decoupled/security/services/UserDetailsImpl.java @@ -0,0 +1,104 @@ +package com.example.demo_decoupled.security.services; + + +import com.example.demo_decoupled.model.User; +import com.fasterxml.jackson.annotation.JsonIgnore; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +public class UserDetailsImpl implements UserDetails { + private static final long serialVersionUID = 1L; + + private Long id; + + private String username; + + private String email; + + @JsonIgnore + private String password; + + private Collection authorities; + + public UserDetailsImpl(Long id, String username, String email, String password, + Collection authorities) { + this.id = id; + this.username = username; + this.email = email; + this.password = password; + this.authorities = authorities; + } + + public static UserDetailsImpl build(User user) { + List authorities = user.getRoles().stream() + .map(role -> new SimpleGrantedAuthority(role.getName().name())) + .collect(Collectors.toList()); + + return new UserDetailsImpl( + user.getId(), + user.getUsername(), + user.getEmail(), + user.getPassword(), + authorities); + } + + @Override + public Collection getAuthorities() { + return authorities; + } + + public Long getId() { + return id; + } + + public String getEmail() { + return email; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getUsername() { + return username; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + UserDetailsImpl user = (UserDetailsImpl) o; + return Objects.equals(id, user.id); + } +} diff --git a/src/main/java/com/example/demo_decoupled/security/services/UserDetailsServiceImpl.java b/src/main/java/com/example/demo_decoupled/security/services/UserDetailsServiceImpl.java new file mode 100644 index 0000000..588eb43 --- /dev/null +++ b/src/main/java/com/example/demo_decoupled/security/services/UserDetailsServiceImpl.java @@ -0,0 +1,29 @@ +package com.example.demo_decoupled.security.services; + + + +import com.example.demo_decoupled.model.User; +import com.example.demo_decoupled.repository.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + + +@Service +public class UserDetailsServiceImpl implements UserDetailsService { + @Autowired + UserRepository userRepository; + + @Override + @Transactional + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("User Not Found with username: " + username)); + + return UserDetailsImpl.build(user); + } + +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..5a37438 --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,28 @@ +spring: + application: + name: demo-deoupled + datasource: + url: jdbc:postgresql://localhost:5432/springangulardb + username: springangular_user + password: postgrespw + flyway: + enabled: true + baseline-on-migrate: true + url: jdbc:postgresql://localhost:5432/springangulardb?options=-c%20timezone=UTC + user: springangular_user + password: postgrespw + jpa: + properties: + hibernate: + jdbc: + lob: + non_contextual_creation: true + dialect: org.hibernate.dialect.PostgreSQLDialect + hibernate: + ddl-auto: update + +app: + jwtSecret: ======================spring=back==================== + jwtExpirationMs: 30000 + jwtCookieName: demo-deoupled + origin: http://localhost:4200 \ No newline at end of file diff --git a/src/main/resources/db/migration/V1__Init.sql b/src/main/resources/db/migration/V1__Init.sql new file mode 100644 index 0000000..b179350 --- /dev/null +++ b/src/main/resources/db/migration/V1__Init.sql @@ -0,0 +1,81 @@ +-- This script was generated by the ERD tool in pgAdmin 4. +-- Please log an issue at https://github.com/pgadmin-org/pgadmin4/issues/new/choose if you find any bugs, including reproduction steps. +BEGIN; + +CREATE TABLE IF NOT EXISTS public.refreshtoken +( + id bigint NOT NULL GENERATED ALWAYS AS IDENTITY ( INCREMENT 1 START 1 MINVALUE 1 MAXVALUE 9223372036854775807 CACHE 1 ), + user_id bigint NOT NULL, + token character varying COLLATE pg_catalog."default" NOT NULL, + expiry_date timestamp with time zone, + CONSTRAINT refreshtoken_pkey PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS public.roles +( + id integer NOT NULL GENERATED BY DEFAULT AS IDENTITY ( INCREMENT 1 START 1 MINVALUE 1 MAXVALUE 2147483647 CACHE 1 ), + name character varying(20) COLLATE pg_catalog."default", + CONSTRAINT roles_pkey PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS public.tutorials +( + id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY ( INCREMENT 1 START 1 MINVALUE 1 MAXVALUE 9223372036854775807 CACHE 1 ), + published boolean, + tobepublished boolean, + title character varying(255) COLLATE pg_catalog."default", + userid bigint, + created timestamp with time zone, + modified timestamp with time zone, + description character varying(255) COLLATE pg_catalog."default", + titleimage text COLLATE pg_catalog."default", + body text COLLATE pg_catalog."default", + CONSTRAINT tutorials_title_key UNIQUE (title) +); + +CREATE TABLE IF NOT EXISTS public.user_roles +( + user_id bigint NOT NULL, + role_id integer NOT NULL, + CONSTRAINT user_roles_pkey PRIMARY KEY (user_id, role_id) +); + +CREATE TABLE IF NOT EXISTS public.users +( + id integer NOT NULL GENERATED BY DEFAULT AS IDENTITY ( INCREMENT 1 START 1 MINVALUE 1 MAXVALUE 2147483647 CACHE 1 ), + email character varying(50) COLLATE pg_catalog."default", + password character varying(120) COLLATE pg_catalog."default", + username character varying(20) COLLATE pg_catalog."default", + CONSTRAINT users_pkey PRIMARY KEY (id), + CONSTRAINT uk6dotkott2kjsp8vw4d0m25fb7 UNIQUE (email), + CONSTRAINT ukr43af9ap4edm43mmtq01oddj6 UNIQUE (username) +); + +ALTER TABLE IF EXISTS public.refreshtoken + ADD CONSTRAINT "FK_refreshtoken_users" FOREIGN KEY (user_id) + REFERENCES public.users (id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION; + + +ALTER TABLE IF EXISTS public.tutorials + ADD CONSTRAINT "tutorial_user_FK" FOREIGN KEY (userid) + REFERENCES public.users (id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION; + + +ALTER TABLE IF EXISTS public.user_roles + ADD CONSTRAINT fkh8ciramu9cc9q3qcqiv4ue8a6 FOREIGN KEY (role_id) + REFERENCES public.roles (id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION; + + +ALTER TABLE IF EXISTS public.user_roles + ADD CONSTRAINT fkhfh9dx7w3ubf1co1vdev94g3f FOREIGN KEY (user_id) + REFERENCES public.users (id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION; + +END; \ No newline at end of file diff --git a/src/main/resources/initData/roles.json b/src/main/resources/initData/roles.json new file mode 100644 index 0000000..412c284 --- /dev/null +++ b/src/main/resources/initData/roles.json @@ -0,0 +1,10 @@ +[ { + "id" : 1, + "name" : "ROLE_USER" +}, { + "id" : 2, + "name" : "ROLE_MODERATOR" +}, { + "id" : 3, + "name" : "ROLE_ADMIN" +} ] \ No newline at end of file diff --git a/src/main/resources/initData/users.json b/src/main/resources/initData/users.json new file mode 100644 index 0000000..683ba55 --- /dev/null +++ b/src/main/resources/initData/users.json @@ -0,0 +1,34 @@ +[ { + "id" : 1, + "username" : "Admin", + "email" : "liosha84@gmail.com", + "password" : "$2a$10$qyoKXYSukha6XCjorTzoweF4Os1pwmwyzbaSsb3RCVB0LK6WLQKPC", + "roles" : [ { + "id" : 1, + "name" : "ROLE_USER" + }, { + "id" : 2, + "name" : "ROLE_MODERATOR" + }, { + "id" : 3, + "name" : "ROLE_ADMIN" + } ] +}, { + "id" : 2, + "username" : "Moderator", + "email" : "Moderator@gmail.com", + "password" : "$2a$10$zLo5th8Xbfq.MM7y/dCRQu3Ud7HHyAwm.7.yS08ytJtkHMKrbOJlu", + "roles" : [ { + "id" : 2, + "name" : "ROLE_MODERATOR" + } ] +}, { + "id" : 3, + "username" : "User", + "email" : "user@gmail.com", + "password" : "$2a$10$2EcwYffteBF3GhVaf5qPB.I7XiHepDEauU5D4fx9fpBXMTZI/QnnC", + "roles" : [ { + "id" : 1, + "name" : "ROLE_USER" + } ] +} ] \ No newline at end of file