[Spring Security] JWT 필터 적용 (2)

신명철·2022년 7월 17일
6

Spring Security

목록 보기
6/6
post-custom-banner

들어가며

JWT를 구현하느라 여기저기 돌아다니며 힘들게 구현했고, 미천하지만 같은 내용을 찾는 분들에게 조금이나마 도움이 되고자 글을 작성하고자 한다.

이전에 JWT 도입 과정 글을 작성했지만, 내용이 모호하고 로직이 쓸데없이 복잡한 것 같아서 조금 간소화해보고자 한다. 마침 캡스톤 프로젝트 때 JWT를 적용해보지 못했기에 리팩터링할 겸 시간을 내게 됐다.


Security Config

  • JwtAuthenticationFilter, JwtAuthorizationFilter 를 추가해줬다.

  • 각 필터들을 Bean으로 등록했다.

  • Proxy Filter Chain의 순서로, JwtAuthorizationFilter -> JwtAuthenticationFilter 로 이루어져 있는 것을 알 수 있다.

  • Header에 Token 값이 없는 경우, JwtAuthorizationFilter를 통과하고 JwtAuthenticationFilter 를 거쳐 로그인을 진행하도록 하고
    Header에 Token 값이 있다면, JwtAuthorizationFilter에서 JWT를 인증하는 과정을 거쳐서 SecurityContextHolderAuthentication 객체를 주입함으로써 인가를 완료할 예정이다.

JwtAuthenticationFilter

JwtAuthenticationFilter는 1차적으로 로그인을 처리하는 Filter이다.
JwtAuthenticationFilter에서는 로그인 값들로 들어온 [username/password]를 가지고 인증 과정을 거친 후 Response Header에 JWT를 담아서 반환해주는 역할을 한다.

@Slf4j
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter{
	
	private final UserService userService;
	private final long VALID_TIME = 1000L * 60 * 60; // 1시간 
	@Value("${jwt.secret}")
	private String SECRET_KEY;
	
	public JwtAuthenticationFilter(UserService userService) {
		this.userService = userService;
		
		setFilterProcessesUrl("/api/login");
	}

	@Override
	public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
		
		log.info("--JWT AUTHENTICATION FILTER--");
		
		try {
			
			UserLoginVO creds = new ObjectMapper().readValue(request.getInputStream(), UserLoginVO.class);
			
			return getAuthenticationManager().authenticate(
					new UsernamePasswordAuthenticationToken(
							creds.getEmail(), 
							creds.getPassword(),
							null
							));
			
		} catch (Exception e) {
			throw new RuntimeException(e);
		} 
		
	}

	@Override
	protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
			Authentication authResult) throws IOException, ServletException {
		
		String email = ((PrincipalDetails)authResult.getPrincipal()).getUsername();
		UserResponse userResponse = userService.findByEmail(email);
		
		String jwtToken = Jwts.builder()
								.setSubject(userResponse.getEmail())
								.setExpiration(new Date(System.currentTimeMillis() + VALID_TIME))
								.signWith(getSecretKey(), SignatureAlgorithm.HS256)
								.compact();
		
		response.addHeader("token", jwtToken);
		response.addHeader("username", userResponse.getUsername());
	}

	private Key getSecretKey() {
		byte[] KeyBytes = SECRET_KEY.getBytes();
		return Keys.hmacShaKeyFor(KeyBytes);
	}
	
}
  • JwtAuthenticationFilterUsernamePasswordAuthenticationFilter를 상속받아서 구현했다. 가장 주의해야 할 점은, UsernamePasswordAuthenticationFilter의 default processing url이 /login으로 설정되어 있기에 url을 변경하고자 한다면 생성자에서 setFilterProcessingUrl메서드를 통해서 수정을 해주어야 한다.
  • JWT의 subject 값으로는 email을 넣어주고, 유효기간은 1시간으로 설정했다.

JwtAuthorizationFilter

JwtAuthorizationFilterJWT의 인가를 처리하는 역할을 한다. JwtAuthenticationFilter에 앞서서 처리해야 하는 이유는 SecurityContextHolderAuthentication을 주입해줌으로써 인가 처리를 해주기 위함이다.

public class JwtAuthorizationFilter extends BasicAuthenticationFilter{

	@Value("${jwt.secret}")
	private String SECRET_KEY;
	private final UserService userService;
	private final PrincipalDetailsService principalDetailsService;
	
	public JwtAuthorizationFilter(AuthenticationManager authenticationManager
								, UserService userService
								, PrincipalDetailsService principalDetailsService) {
		
		super(authenticationManager);
		this.userService = userService;
		this.principalDetailsService = principalDetailsService;
	}

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
			throws IOException, ServletException {
		
		try {
			String tokenHeader = request.getHeader("Authorization");
			String jwtToken = null;
			
			if(StringUtils.hasText(tokenHeader) && tokenHeader.startsWith("Bearer")) {
				jwtToken = tokenHeader.replace("Bearer ", "");
			}
		
			if(jwtToken != null && isValid(jwtToken)) {
				SecurityContextHolder.getContext().setAuthentication(getAuth(jwtToken));
			}
			
		}catch (Exception e) {
			throw new RuntimeException(e);
		}
		
		chain.doFilter(request, response);
	}

	private Authentication getAuth(String jwtToken) {
		PrincipalDetails user = (PrincipalDetails)principalDetailsService.loadUserByUsername(getEmail(jwtToken));
		return new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword(), user.getAuthorities());
	}
	
	private String getEmail(String jwtToken) {
		return Jwts.parserBuilder()
					.setSigningKey(getSecretKey())
					.build()
					.parseClaimsJws(jwtToken).getBody()
					.getSubject();
	}
	
	private boolean isValid(String jwtToken) {
		boolean ret = true; 
		
		Jws<Claims> jws = null;
		
		try {
			jws = Jwts.parserBuilder()
				.setSigningKey(getSecretKey())
				.build()
				.parseClaimsJws(jwtToken);
		
			if( jws == null ||
				jws.getBody().getSubject() == null ||
				jws.getBody().getExpiration().before(new Date())) {
				ret = false;
			}
			
		}catch (Exception e) {
			ret = false;
		}
		return ret;
	}
	
	private Key getSecretKey() {
		byte[] keyBytes = SECRET_KEY.getBytes();
		return Keys.hmacShaKeyFor(keyBytes);
	}
}

PrincipalDeatilsService

@Slf4j
@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService{

	private final UserRepository userRepository;
	
	@Override
	public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
		
		log.info("email :: {}",email);
		
		User user = userRepository.findByEmail(email) 
								.orElseThrow(() -> new IllegalArgumentException("회원 없음"));
			
		log.info("LOAD USER BY USERNAME = USER : {}, {}",user.getEmail(), user.getPassword());
		
		return new PrincipalDetails(user);
	}

}
  • AuthenticationProvider가 호출할 loadUserByUsername 메서드다. DB에 존재하는 데이터를 가져와서 JwtAuthenticationFilter에서 만들어서 return 했었던 UsernamePasswordAuthenticationToken와 비교하는 과정을 거치게 된다.

PrincipalDetails

@Slf4j
public class PrincipalDetails implements UserDetails{

	private User user;
	
	public PrincipalDetails(User user) {
		this.user = user;
	}

	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		Collection<GrantedAuthority> authorities = new ArrayList<>();
		authorities.add(new SimpleGrantedAuthority(user.getRole().name()));
		return authorities;
	}

	@Override
	public String getPassword() {
		return user.getPassword();
	}

	@Override
	public String getUsername() {
		return user.getEmail();
	}

	@Override
	public boolean isAccountNonExpired() {
		return true;
	}

	@Override
	public boolean isAccountNonLocked() {
		return true;
	}

	@Override
	public boolean isCredentialsNonExpired() {
		return true;
	}

	@Override
	public boolean isEnabled() {
		return true;
	}
}
  • VO 역할을 하는 UserDetails의 구현체다.
profile
내 머릿속 지우개
post-custom-banner

1개의 댓글

comment-user-thumbnail
2023년 5월 26일

와 진짜 감사합니다 필터이용해서 구현중에 안 풀리는 문제가 있었는데 덕분에 해결했습니다

답글 달기