JWT를 구현하느라 여기저기 돌아다니며 힘들게 구현했고, 미천하지만 같은 내용을 찾는 분들에게 조금이나마 도움이 되고자 글을 작성하고자 한다.
이전에 JWT 도입 과정 글을 작성했지만, 내용이 모호하고 로직이 쓸데없이 복잡한 것 같아서 조금 간소화해보고자 한다. 마침 캡스톤 프로젝트 때 JWT를 적용해보지 못했기에 리팩터링할 겸 시간을 내게 됐다.
JwtAuthenticationFilter
, JwtAuthorizationFilter
를 추가해줬다.Proxy Filter Chain의 순서로, JwtAuthorizationFilter
-> JwtAuthenticationFilter
로 이루어져 있는 것을 알 수 있다.
Header에 Token 값이 없는 경우, JwtAuthorizationFilter
를 통과하고 JwtAuthenticationFilter
를 거쳐 로그인을 진행하도록 하고
Header에 Token 값이 있다면, JwtAuthorizationFilter
에서 JWT
를 인증하는 과정을 거쳐서 SecurityContextHolder
에 Authentication
객체를 주입함으로써 인가를 완료할 예정이다.
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);
}
}
JwtAuthenticationFilter
는 UsernamePasswordAuthenticationFilter
를 상속받아서 구현했다. 가장 주의해야 할 점은, UsernamePasswordAuthenticationFilter
의 default processing url이 /login
으로 설정되어 있기에 url을 변경하고자 한다면 생성자에서 setFilterProcessingUrl
메서드를 통해서 수정을 해주어야 한다.JWT
의 subject 값으로는 email
을 넣어주고, 유효기간은 1시간으로 설정했다.JwtAuthorizationFilter
는 JWT
의 인가를 처리하는 역할을 한다. JwtAuthenticationFilter
에 앞서서 처리해야 하는 이유는 SecurityContextHolder
에 Authentication
을 주입해줌으로써 인가 처리를 해주기 위함이다.
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);
}
}
@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
와 비교하는 과정을 거치게 된다.@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;
}
}
UserDetails
의 구현체다.
와 진짜 감사합니다 필터이용해서 구현중에 안 풀리는 문제가 있었는데 덕분에 해결했습니다