🔐 로그인 기능이 포함된 게시판 백엔드 서버를 만들면서, 회원인 경우에만 게시글 작성, 수정, 삭제를 할 수 있도록 JWT를 활용하여 인증/인가를 구현했다.
로그인을 성공하면, 성공한 사용자의 정보와 JWT를 활용하여 토큰을 발급했고 Header에 토큰을 추가해서 Client에게 반환했다.
Client는 게시글을 작성, 수정, 삭제할 때 request header에 토큰을 넣어 요청을 보내고, Service 에서 토큰을 검증하고, 토큰으로부터 사용자 정보를 가져와 회원DB에 사용자가 있는지 찾는 과정을 반복했다.
그런데 서비스에 있는 게시글 작성 메서드, 게시글 수정 메서드, 게시글 삭제 메서드는 작성, 수정, 삭제라는 로직만 구현하는게 아니라, 인증/인가 관련(토큰 검증과 회원DB 조회) 로직을 함께 수행하고 있다.
인증/인가 관련 코드가 메서드마다 반복되고 있으며, 하나의 메서드에서 두 개 이상의 로직을 구현하고 있어서 객체지향과 거리가 생겼다.
인증/인가 부분을 따로 처리할 수 있는 방법이 없을까?
바로 Spring Security 프레임워크를 사용하면 이 고민을 해결할 수 있다.
지금부터 차근차근 Spring Security를 적용해보자!
Filter
SecurityFilterChain
Spring의 보안 Filter를 결정하는데 사용되는 Filter이다.
session, jwt 등 인증방식을 사용할 때 필요한 설정을 서비스 로직 구현으로부터 분리할 수 있는 환경을 제공한다.
SecurityFilterChain에는 여러 개의 Security Filter들이 있는데, 우선 UsernamePasswrodAuthenticationFilter
만 살펴볼 예정이다.
UsernamePasswordAuthenticationFilter
SecurityContextHolder
SecurityContextHolder
: Spring Security로 인증한 사용자의 상세 정보를 저장한다.SecurityContext
: SecurityContextHolder
로 접근할 수 있으며, Authentication
객체를 갖는다.Authentication
SecurityContext
에서 가져올 수 있다.principal
: 사용자를 식별한다. username/password 방식으로 인증할 때 보통 UserDetails
인스턴스다.credentials
: 주로 비밀번호 정보이다. 대부분 사용자 인증에 사용한 다음 비운다.authorities
: 사용자에게 부여한 권한을 GrantedAuthority
로 추상화하여 사용한다.UserDetailsService
UserDetails
UsernamePasswordAuthenticationToken
타입의 Authentication
을 만들 때 사용된다.SecurityContextHolder
에 세팅된다.프로젝트 환경 : springboot 2.7.8, java 17
build.gradle에 Spring Security 프레임워크 추가
// 스프링 시큐리티
implementation 'org.springframework.boot:spring-boot-starter-security'
Spring Security 활성화 → WebSecurityConfig
추가
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@EnableGlobalMethodSecurity(securedEnabled = true)
public class WebSecurityConfig {
private final JwtUtil jwtUtil; // Jwt를 사용할 것이므로 선언
@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(HttpMethod.GET, "/api/posts").permitAll()
.antMatchers(HttpMethod.GET, "/api/post/{id}").permitAll()
.anyRequest().authenticated()
.and().addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);
http.formLogin().permitAll();
return http.build();
}
}
jwtUtil
: 토큰 생성, 토큰 검증, 토큰에서 사용자 정보 가져오기 기능이 구현된 클래스addFilterBefore
: UsernamePasswordAuthenticationFilter
필터 전에 JwtAuthFilter(jwtUtil)
을 거친다.JwtAuthFilter : Jwt 사용을 위해 커스텀한 시큐리티 필터
@Slf4j
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// request 에 담긴 토큰을 가져온다.
String token = jwtUtil.resolveToken(request);
// 토큰이 null 이면 다음 필터로 넘어간다.
if (token == null) {
filterChain.doFilter(request, response);
return;
}
// 토큰이 유효하지 않으면 예외처리
if (!jwtUtil.validateToken(token)) {
jwtExceptionHandler(response, ErrorType.NOT_VALID_TOKEN);
return;
}
// 유효한 토큰이라면, 토큰으로부터 사용자 정보를 가져온다.
Claims info = jwtUtil.getUserInfoFromToken(token);
setAuthentication(info.getSubject()); // 사용자 정보로 인증 객체 만들기
// 다음 필터로 넘어간다.
filterChain.doFilter(request, response);
}
private void setAuthentication(String username) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
Authentication authentication = jwtUtil.createAuthentication(username); // 인증 객체 만들기
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
}
// 토큰에 대한 오류가 발생했을 때, 커스터마이징해서 Exception 처리 값을 클라이언트에게 알려준다.
public void jwtExceptionHandler(HttpServletResponse response, ErrorType error) {
response.setStatus(error.getCode());
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
try {
String json = new ObjectMapper().writeValueAsString(new MessageResponseDto(error.getMessage(), error.getCode()));
response.getWriter().write(json);
} catch (Exception e) {
log.error(e.getMessage());
}
}
}
setAuthentication
: 인증 객체 (authentication
)를 만들어 securityContext
안에 넣은 후, SecurityContextHolder
안에 넣는다.SeccurityContextHolder
안에 Spring Security로 인증한 사용자의 상세정보가 담긴 인증 객체가 만들어졌다면, 다음 필터로 넘어간다.jwtExceptionHandler
를 통해 예외처리한다.JwtUtil
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtUtil {
private final UserDetailsServiceImpl userDetailsService;
// header 토큰 가져오기
// token 생성
// token 검증
// token에서 사용자 정보 가져오기
// ...
// 인증 객체 생성
public Authentication createAuthentication(String username) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
}
}
createAuthentication
: userDetails
를 사용해서 UsernamePasswordAuthenticationToken
타입의 Authentication
을 만든다.UserDetailsServiceImpl
@Service // custom 하고 Bean 으로 등록 후 사용 가능
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException(ErrorType.NOT_FOUND_USER.getMessage())); // 사용자가 DB 에 없으면 예외처리
return new UserDetailsImpl(user, user.getUsername()); // 사용자 정보를 UserDetails 로 반환
}
}
loadUserByUsername
: 사용자가 DB에 있는지 찾고, 있다면 UserDetailsImpl
에 사용자 객체를 담아서 리턴한다.UserDetailsImpl
public class UserDetailsImpl implements UserDetails {
private final User user;
private final String username;
// 인증이 완료된 사용자 추가하기
public UserDetailsImpl(User user, String username) {
this.user = user;
this.username = username;
}
public User getUser() {
return user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
UserRoleEnum role = user.getRole();
String authority = role.getAuthority();
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(authority);
Collection<GrantedAuthority> authorities = new ArrayList<>(); // 사용자 권한을 GrantedAuthority 로 추상화
authorities.add(simpleGrantedAuthority);
return authorities; // GrantedAuthority 로 추상화된 사용자 권한 반환
}
@Override
public String getPassword() {
return null;
}
@Override
public String getUsername() {
return this.username;
}
@Override
public boolean isAccountNonExpired() {
return false;
}
@Override
public boolean isAccountNonLocked() {
return false;
}
@Override
public boolean isCredentialsNonExpired() {
return false;
}
@Override
public boolean isEnabled() {
return false;
}
}
getAuthorities
: 사용자 권한을 GrantedAuthority
로 추상화한다.BoardController
@RestController
@RequiredArgsConstructor
public class BoardController {
private final BoardService boardService;
// 게시글 작성
@PostMapping("/api/post")
public ResponseEntity<BoardResponseDto> createPost(@RequestBody BoardRequestsDto requestsDto, @AuthenticationPrincipal UserDetailsImpl userDetails) {
return boardService.createPost(requestsDto, userDetails.getUser());
}
// 선택된 게시글 조회
@GetMapping("/api/post/{id}")
public ResponseEntity<BoardResponseDto> getPost(@PathVariable Long id) {
return boardService.getPost(id);
}
}
@AuthenticationPrincipal
: SecurityContextHolder 안에 인증된 사용자의 객체 정보에서 principal 부분을 가져올 수 있다.userDetails.getUser()
로 인증된 사용자 객체를 바로 넘길 수 있다.BoardService
@Service
@RequiredArgsConstructor
public class BoardService {
private final BoardRepository boardRepository;
// 게시글 작성
@Transactional
public ResponseEntity<BoardResponseDto> createPost(BoardRequestsDto requestsDto, User user) {
// 작성 글 저장
Board board = boardRepository.save(Board.builder()
.requestsDto(requestsDto)
.user(user)
.build());
// BoardResponseDto 변환
BoardResponseDto responseDto = BoardResponseDto.builder()
.entity(board)
.build();
// responseEntity 로 반환
return ResponseEntity.ok(responseDto);
}
// 선택된 게시글 조회
@Transactional(readOnly = true)
public ResponseEntity<BoardResponseDto> getPost(Long id) {
// Id에 해당하는 게시글이 있는지 확인
Optional<Board> board = boardRepository.findById(id);
if (board.isEmpty()) { // 해당 게시글이 없다면
throw new RestApiException(ErrorType.NOT_FOUND_WRITING);
}
// 댓글리스트 작성일자 기준 내림차순 정렬
board.get()
.getCommentList()
.sort(Comparator.comparing(Comment::getModifiedAt)
.reversed());
BoardResponseDto responseDto = BoardResponseDto.builder() // DTO 로 변환
.entity(board.get())
.build();
// ResponseEntity body 에 dto 담아 리턴
return ResponseEntity.ok(responseDto);
}
}