🎯 목표 : JWT 개념 학습, Spring Security 어플리케이션에 JWT를 구현하는 과정 학습
📒 Spring Security + JWT
📌 JWT 생성 클래스
@Component
public class JwtTokenizer {
@Getter
@Value("${jwt.key.secret}")
private String secretKey;
@Getter
@Value("${jwt.access-token-expiration-minutes}")
private int accessTokenExpirationMinutes;
@Getter
@Value("${jwt.refresh-token-expiration-minutes}")
private int refreshTokenExpirationMinutes;
public String encodeBase64SecretKey(String secretKey) {
return Encoders.BASE64.encode(secretKey.getBytes(StandardCharsets.UTF_8));
}
public String generateAccessToken(
Map<String, Object> claims,
String subject,
Date expiration,
String base64EncodedSecretKey
) {
Key key = getKeyFromBase64EncodedSecretKey(base64EncodedSecretKey);
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(Calendar.getInstance().getTime())
.setExpiration(expiration)
.signWith(key)
.compact();
}
public String generateRefreshToken(
String subject,
Date expiration,
String base64EncodedSecretKey
) {
Key key = getKeyFromBase64EncodedSecretKey(base64EncodedSecretKey);
return Jwts.builder()
.setSubject(subject)
.setIssuedAt(Calendar.getInstance().getTime())
.setExpiration(expiration)
.signWith(key)
.compact();
}
private Key getKeyFromBase64EncodedSecretKey(String base64EncodedSecretKey) {
byte[] keyBytes = Decoders.BASE64.decode(base64EncodedSecretKey);
return Keys.hmacShaKeyFor(keyBytes);
}
public void verifySignature(String jws, String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedSecretKey(base64EncodedSecretKey);
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(jws);
}
public Jws<Claims> getClaims(String jws, String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedSecretKey(base64EncodedSecretKey);
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(jws);
}
public Date getTokenExpiration(int expirationMinutes) {
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.MINUTE, expirationMinutes);
return calendar.getTime();
}
}
secretKey
(비밀키),accessTokenExpirationMinutes
(AccessToken 만료시간),refreshTokenExpirationMinutes
(RefreshToken 만료시간)는 설정 파일이나, 시스템 환경변수로 셋팅 해놓으면 된다.
encodeBase64SecretKey()
는 Secrit key byte를 Base64 형식으로 인코딩 해준다.
generateAccessToken()
은 JWT를 최초 발급해 주는 메소드다.
- Claims는 인증된 사용자와 관련된 정보를 추가한다.
- Subject는 JWT에 대한 제목을 추가한다.
signWith()
에서는 Key
객체를 설정하는데 Signature 부분을 설정한다.
compace()
는 JWT를 생성하고 직렬화한다.
generateRefreshToken()
에서는 Access Token이 만료되면 재발급 할 수 있게 해주는 Refresh Token을 생성한다.
getKeyFromBase64EncodedKey()
에서는 JWT의 서명에 사용하는 Secret key를 생성한다.
📌 Authentication Filter 구현
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final JwtTokenizer jwtTokenizer;
@SneakyThrows
@Override
public Authentication attemptAuthentication(
HttpServletRequest request,
HttpServletResponse response
) throws AuthenticationException {
ObjectMapper objectMapper = new ObjectMapper();
LoginDto loginDto = objectMapper.readValue(request.getInputStream(), LoginDto.class);
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());
return authenticationManager.authenticate(usernamePasswordAuthenticationToken);
}
@Override
protected void successfulAuthentication(
HttpServletRequest request,
HttpServletResponse response,
FilterChain chain,
Authentication authResult
) throws IOException, ServletException {
Member member = (Member) authResult.getPrincipal();
String accessToken = delegateAccessToken(member);
String refreshToken = delegateRefreshToken(member);
response.setHeader("Authorization", "Bearer " + accessToken);
response.setHeader("Refresh", refreshToken);
this.getSuccessHandler().onAuthenticationSuccess(request, response, authResult);
}
private String delegateAccessToken(Member member) {
HashMap<String, Object> claims = new HashMap<>();
claims.put("username", member.getEmail());
claims.put("roles", member.getRoles());
String subject = member.getEmail();
Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getAccessTokenExpirationMinutes());
String base64SecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
return jwtTokenizer.generateAccessToken(claims, subject, expiration, base64SecretKey);
}
private String delegateRefreshToken(Member member) {
String subject = member.getEmail();
Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getRefreshTokenExpirationMinutes());
String base64SecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
return jwtTokenizer.generateRefreshToken(subject, expiration, base64SecretKey);
}
}
- Username/Password기반 인증을 처리하기위해
UsernamePasswordAuthenticationFilter
를 확장하여 구현하였다.
attemptAuthentication()
는 인증 시도 로직으로 오버라이딩하여 구현하였다.
successfulAuthentication()
는 클라이언트의 인증 정보를 이용하여 인증에 성공할 경우 호출된다.
delegateAccessToken(Member member)
에서 Access Token을 생성하여 response Header에 넣어준다.
delegateRefreshToken(Member member)
에서 Refresh Token을 생성하여 response Header에 넣어준다.
- 로그인 인증 요청을 하고 Token을 발급 받는 코드를 구현하였다. Config에 적용을 해보자.
📌 Config 적용
@Configuration
public class SecurityConfiguration {
private final JwtTokenizer jwtTokenizer;
public SecurityConfiguration(JwtTokenizer jwtTokenizer) {
this.jwtTokenizer = jwtTokenizer;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.headers().frameOptions().sameOrigin()
.and()
.csrf().disable()
.cors(withDefaults())
.formLogin().disable()
.httpBasic().disable()
.apply(new CustomFilterConfigurer())
.and()
.authorizeHttpRequests(authorize -> authorize
.anyRequest().permitAll()
);
return http.build();
}
public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> {
@Override
public void configure(HttpSecurity builder) throws Exception {
AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);
JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager, jwtTokenizer);
jwtAuthenticationFilter.setFilterProcessesUrl("/login");
builder.addFilter(jwtAuthenticationFilter);
}
}
}
- 로그인 인증까지의 코드를 적용해주기 위해
SecurityConfiguration
에 필터를 적용시켜 주었다.
AbstractHttpConfigurer
를 확장하여 CustomFilterConfigurer
를 내부 클래스로 확장 하였다.
AbstractHttpConfigurer
에서 getSharedObject(AuthenticationManager.class)
를 통해 AuthenticationManager
객체를 얻을수 있으며 얻은 객체와 JwtTokenizer
를 JwtAuthenticationFilter
에 생성자로 주입하고 HttpSecurity
필터에 추가한다.
- 추가한 필터를 적용하기 위해서는 Bean으로 등록한 FilterChain에서
.apply(new CustomFilterConfigurer())
로 AbstractHttpConfigurer
확장 클래스를 추가 해주면 된다.
- 이후 로그인 인증 성공과 실패에 따른 추가 처리에 대한 코드를 추가하려면,
AuthenticationSuccessHandler
, AuthenticationFailureHandler
를 구현하여 Config에 아래와 같이 추가해 주면 된다, 상세 코드는 생략하고 권한 검증에 대한 코드를 알아보자.
public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> {
@Override
public void configure(HttpSecurity builder) throws Exception {
AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);
JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager, jwtTokenizer);
jwtAuthenticationFilter.setFilterProcessesUrl("/login");
jwtAuthenticationFilter.setAuthenticationSuccessHandler(new MemberAuthenticationSuccessHandler());
jwtAuthenticationFilter.setAuthenticationFailureHandler(new MemberAuthenticationFailureHandler());
builder.addFilter(jwtAuthenticationFilter);
}
}