Project MySelectShop - Security
👉 Security에서 JWT를 사용한 인증/인가의 흐름
- 사용자는 회원가입을 진행한다.
- 해당 URI 요청은 permitAll 처리하고 사용자의 입력값으로 service에서 회원가입을 진행한다.
- 사용자의 정보를 저장할 때 비밀번호를 암호화하여 저장한다.
- PasswordEncoder를 사용하여 비밀번호를 암호화 한 후 저장한다.
- 사용자는 로그인을 진행한다.
- 해당 URI 요청은 permitAll 처리하고 사용자의 입력값으로 service에서 회원 인증을 진행한다. (비밀번호 일치여부 등)
- 사용자 인증을 성공하면 사용자의 정보를 사용하여 JWT 토큰을 생성하고 Header에 추가하여 반환한다. Client 는 이를 쿠키저장소에 저장한다.
- 사용자는 게시글 작성과 같은 요청을 진행할 때 발급받은 JWT 토큰을 같이 보낸다.
- 서버는 JWT 토큰을 검증하고 토큰의 정보를 사용하여 사용자의 인증을 진행해주는 Spring Security 에 등록한 Custom Security Filter 를 사용하여 인증/인가를 처리한다.
- Custom Security Filter에서 SecurityContextHolder 에 인증을 완료한 사용자의 상세 정보를 저장하는데 이를 통해 Spring Security 에 인증이 완료 되었다는 것을 알려준다.
👉 JwtAuthFilter.java 추가
@Slf4j
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// FilterChain을 통해서 다음 필터로 이동
// authorization의 Bearer가 붙은 키 값으로 request Header 부분에 토큰을 가져온다.
String token = jwtUtil.resolveToken(request); // 토큰을 가져오는 함수
// 모든 URI가 permitAll로 들어오는것이 아니기 때문에 토큰이 있는지 없는지 분기처리 / ex)회원가입 등
if(token != null) {
if(!jwtUtil.validateToken(token)){
jwtExceptionHandler(response, "Token Error", HttpStatus.UNAUTHORIZED.value());
return;
}
// Claims 객체쪽으로 토큰정보를 가져오고
Claims info = jwtUtil.getUserInfoFromToken(token);
// setAuthentication 안에 있는 Subject를 받는다.
setAuthentication(info.getSubject());
}
filterChain.doFilter(request,response); // 토큰이 없다면 다음 필터로 이동
}
public void setAuthentication(String username) {
// SecurityContext 객체를 만들어서
SecurityContext context = SecurityContextHolder.createEmptyContext();
// Authentication 인증 객체를 넣은 다음에
Authentication authentication = jwtUtil.createAuthentication(username);
context.setAuthentication(authentication);
// 만든것을 SecurityContextHolder에 넣는다.
SecurityContextHolder.setContext(context);
}
// 토큰에 대한 오류가 발생 했을 때 Client로 커스터마이징 한 Exception처리 값을 알려주는 함수
public void jwtExceptionHandler(HttpServletResponse response, String msg, int statusCode) {
response.setStatus(statusCode);
response.setContentType("application/json");
try {
String json = new ObjectMapper().writeValueAsString(new SecurityExceptionDto(statusCode, msg));
response.getWriter().write(json);
} catch (Exception e) {
log.error(e.getMessage());
}
}
👉 WebSecurityConfig.java 추가
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity // 스프링 Security 지원을 가능하게 함
@EnableGlobalMethodSecurity(securedEnabled = true) // @Secured 어노테이션 활성화
public class WebSecurityConfig {
private final JwtUtil jwtUtil;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
// h2-console 사용 및 resources 접근 허용 설정
return (web) -> web.ignoring()
.requestMatchers(PathRequest.toH2Console())
.requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf().disable();
// 기본 설정인 Session 방식은 사용하지 않고 JWT 방식을 사용하기 위한 설정
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
http.authorizeRequests().antMatchers("/api/user/**").permitAll()
.antMatchers("/api/search").permitAll()
.antMatchers("/api/shop").permitAll()
.anyRequest().authenticated()
// JWT 인증/인가를 사용하기 위한 설정
.and().addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);
http.formLogin().loginPage("/api/user/login-page").permitAll();
http.exceptionHandling().accessDeniedPage("/api/user/forbidden");
return http.build();
}
👉 JwtUtil.java 수정
private final UserDetailsServiceImpl userDetailsService;
// 책임을 분리하기 위해 코드를 줄이기 위해
// createAuthentication 인증객체를 만드는 부분은 JwtUtil로 뺌.
...
// 인증 객체 생성
public Authentication createAuthentication(String username) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
}
👉 UserService.java 수정
public void signup(SignupRequestDto signupRequestDto) {
String username = signupRequestDto.getUsername();
// 패스워드를 인코더 해서
String password = passwordEncoder.encode(signupRequestDto.getPassword());
}
public void login(LoginRequestDto loginRequestDto, HttpServletResponse response) {
// 인코더된 패스워드 일치여부 확인
if(!passwordEncoder.matches(password, user.getPassword())){
throw new IllegalArgumentException("비밀번호가 일치하지 않습니다.");
}
response.addHeader(JwtUtil.AUTHORIZATION_HEADER, jwtUtil.createToken(user.getUsername(), user.getRole()));
}
👉 ShopController.java 수정
// 로그인 한 유저가 메인페이지를 요청할 때 유저의 이름 반환
@GetMapping("/user-info")
@ResponseBody
public String getUserName(@AuthenticationPrincipal UserDetailsImpl userDetails) {
return userDetails.getUsername();
}
👉 ProductController.java 수정
// 관심 상품 등록하기
@Secured(UserRoleEnum.Authority.ADMIN)
@PostMapping("/products")
// @AuthenticationPrincipal 사용해서 인증객체에 담겨져 있는 UserDetailsImpl을 파라미터로 요청받아 받아 올 수 있다.
public ProductResponseDto createProduct(@RequestBody ProductRequestDto requestDto, @AuthenticationPrincipal UserDetailsImpl userDetails) {
// 응답 보내기
return productService.createProduct(requestDto, userDetails.getUser());
}
// 관심 상품 최저가 등록하기
@PutMapping("/products/{id}")
// @AuthenticationPrincipal 사용해서 인증객체에 담겨져 있는 UserDetailsImpl을 파라미터로 요청받아 받아 올 수 있다.
public Long updateProduct(@PathVariable Long id, @RequestBody ProductMypriceRequestDto requestDto, @AuthenticationPrincipal UserDetailsImpl userDetails) {
// 응답 보내기 (업데이트된 상품 id)
return productService.updateProduct(id, requestDto, userDetails.getUser());
}
👉 ProductServicejava 수정
@Service
@RequiredArgsConstructor
public class ProductService {
private final FolderRepository folderRepository;
private final ProductRepository productRepository;
@Transactional
public ProductResponseDto createProduct(ProductRequestDto requestDto, User user) {
System.out.println("ProductService.createProduct");
System.out.println("user.getUsername() = " + user.getUsername());
// 요청받은 DTO 로 DB에 저장할 객체 만들기
Product product = productRepository.saveAndFlush(new Product(requestDto, user.getId()));
return new ProductResponseDto(product);
}
@Transactional(readOnly = true)
public Page<Product> getProducts(User user,
int page, int size, String sortBy, boolean isAsc) {
// 페이징 처리
Sort.Direction direction = isAsc ? Sort.Direction.ASC : Sort.Direction.DESC;
Sort sort = Sort.by(direction, sortBy);
Pageable pageable = PageRequest.of(page, size, sort);
// 사용자 권한 가져와서 ADMIN 이면 전체 조회, USER 면 본인이 추가한 부분 조회
UserRoleEnum userRoleEnum = user.getRole();
Page<Product> products;
if (userRoleEnum == UserRoleEnum.USER) {
// 사용자 권한이 USER일 경우
products = productRepository.findAllByUserId(user.getId(), pageable);
} else {
products = productRepository.findAll(pageable);
}
return products;
}
@Transactional
public Long updateProduct(Long id, ProductMypriceRequestDto requestDto, User user) {
Product product = productRepository.findByIdAndUserId(id, user.getId()).orElseThrow(
() -> new NullPointerException("해당 상품은 존재하지 않습니다.")
);
product.update(requestDto);
return product.getId();
}
@Transactional
public void updateBySearch(Long id, ItemDto itemDto) {
Product product = productRepository.findById(id).orElseThrow(
() -> new NullPointerException("해당 상품은 존재하지 않습니다.")
);
product.updateByItemDto(itemDto);
}
@Transactional
public Product addFolder(Long productId, Long folderId, User user) {
// 1) 상품을 조회합니다.
Product product = productRepository.findById(productId)
.orElseThrow(() -> new NullPointerException("해당 상품 아이디가 존재하지 않습니다."));
// 2) 관심상품을 조회합니다.
Folder folder = folderRepository.findById(folderId)
.orElseThrow(() -> new NullPointerException("해당 폴더 아이디가 존재하지 않습니다."));
// 3) 조회한 폴더와 관심상품이 모두 로그인한 회원의 소유인지 확인합니다.
Long loginUserId = user.getId();
if (!product.getUserId().equals(loginUserId) || !folder.getUser().getId().equals(loginUserId)) {
throw new IllegalArgumentException("회원님의 관심상품이 아니거나, 회원님의 폴더가 아닙니다~^^");
}
// 중복확인
Optional<Product> overlapFolder = productRepository.findByIdAndFolderList_Id(product.getId(), folder.getId());
if (overlapFolder.isPresent()) {
throw new IllegalArgumentException("중복된 폴더입니다.");
}
// 4) 상품에 폴더를 추가합니다.
product.addFolder(folder);
return product;
}
}
🙋♂️ 소감 :
request 객체를 파라미터로 받아오고 Service 로직 부분에서 토큰을 검증하고 User데이터베이스에 접근해서 가져오는 부분이 굉장히 길었는데, 로직은 변경되지 않고 시큐리티를 사용하니 그냥 토큰을 가지고 와서 검증하고 User 확인을 하고 User를 가지고 오는 그러한 코드들이 커스텀한 JwtAuthFilter에서 수행이 돼서 인증이 완료된 User의 정보를 Controller 쪽으로 가지고 와서 받아 오기 때문에 코드가 절반으로 줄었다.
내부적으로 굉장히 많은 로직들이 돌아가고 있어 다 이해하기는 힘들지만, 이번 강의를 통해 대충 Security 로직을 알 수 있었다.
😈 아는 내용이라고 그냥 넘어가지 않기! 😈
'❤️🔥TIL (Today I Learned)' 카테고리의 다른 글
[TIL] 2022-12-29(44day) (0) | 2022.12.29 |
---|---|
[TIL] 2022-12-28(43day) (0) | 2022.12.28 |
[TIL] 2022-12-26(41day) (0) | 2022.12.27 |
[TIL] 2022-12-23(40day) (0) | 2022.12.26 |
[TIL] 2022-12-22(39day) (0) | 2022.12.22 |
댓글