JWT 로그인 구현 복습
JWT 자격 증명을 위한 로그인 인증 구현
1️⃣ Custom UserDetailsService 구현
Spring Security에서 사용자의 로그인 인증을 처리하는 가장 단순하고 효과적인 방법은 데이터베이스에서 사용자의 크리덴셜을 조회한 후, 조회한 크리덴셜을 AuthenticationManager에게 전달하는 Custom UserDetailsService를 구현하는 것입니다.
MemberDetailsService
package com.springboot.auth.userdetails;
import com.springboot.auth.utils.CustomAuthorityUtils;
import com.springboot.exception.BusinessLogicException;
import com.springboot.exception.ExceptionCode;
import com.springboot.member.entity.Member;
import com.springboot.member.repository.MemberRepository;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;
import java.util.Collection;
import java.util.Optional;
@Component
public class MemberDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
private final CustomAuthorityUtils authorityUtils;
public MemberDetailsService(MemberRepository memberRepository, CustomAuthorityUtils authorityUtils) {
this.memberRepository = memberRepository;
this.authorityUtils = authorityUtils;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<Member> optionalMember = memberRepository.findByEmail(username);
Member findMember = optionalMember.orElseThrow(() -> new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND));
return new MemberDetails(findMember);
}
private final class MemberDetails extends Member implements UserDetails {
// (1)
MemberDetails(Member member) {
setMemberId(member.getMemberId());
setEmail(member.getEmail());
setPassword(member.getPassword());
setRoles(member.getRoles());
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorityUtils.createAuthorities(this.getRoles());
}
@Override
public String getUsername() {
return 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;
}
}
}
코드 4-65는 UserDetailsService를 구현한 MemberDetailsService 클래스입니다.
2️⃣ 로그인 인증 정보 역직렬화(Deserialization)를 위한 LoginDTO 클래스 생성
LoginDTO 클래스는 클라이언트가 전송한 Username/Password 정보를 Security Filter에서 사용할 수 있도록 역직렬화(Deserialization) 하기 위한 DTO 클래스입니다.
LoginDto
@Getter
public class LoginDto {
private String username;
private String password;
}
LoginDto 클래스는 클라이언트의 Username과 Passoword 정보만 담는 단순한 DTO 클래스입니다.
3️⃣ JWT를 생성하는 JwtTokenizer 구현
JwtTokenizer 클래스는 로그인 인증에 성공한 클라이언트에게 JWT를 생성 및 발급하고 클라이언트의 요청이 들어올 때마다 전달된 JWT를 검증하는 역할을 합니다.
JwtTokenizer
package com.springboot.auth.jwt;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.io.Encoders;
import io.jsonwebtoken.security.Keys;
import lombok.Getter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Calendar;
import java.util.Date;
import java.util.Map;
// (1)
@Component
public class JwtTokenizer {
@Getter
@Value("${jwt.key}")
private String secretKey; // (2)
@Getter
@Value("${jwt.access-token-expiration-minutes}")
private int accessTokenExpirationMinutes; // (3)
@Getter
@Value("${jwt.refresh-token-expiration-minutes}")
private int refreshTokenExpirationMinutes; // (4)
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 = getKeyFromBase64EncodedKey(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 = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
return Jwts.builder()
.setSubject(subject)
.setIssuedAt(Calendar.getInstance().getTime())
.setExpiration(expiration)
.signWith(key)
.compact();
}
public Jws<Claims> getClaims(String jws, String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
Jws<Claims> claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(jws);
return claims;
}
public void verifySignature(String jws, String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(jws);
}
// (5)
public Date getTokenExpiration(int expirationMinutes) {
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.MINUTE, expirationMinutes);
Date expiration = calendar.getTime();
return expiration;
}
private Key getKeyFromBase64EncodedKey(String base64EncodedSecretKey) {
byte[] keyBytes = Decoders.BASE64.decode(base64EncodedSecretKey);
Key key = Keys.hmacShaKeyFor(keyBytes);
return key;
}
}
application.yml
...
...
jwt:
key: ${JWT_SECRET_KEY} # 민감한 정보는 시스템 환경 변수에서 로드한다.
access-token-expiration-minutes: 30
refresh-token-expiration-minutes: 420
코드 4-78은 JWT 생성 및 검증에 사용되는 정보가 추가된 application.yml 파일의 코드 일부입니다.
- JWT의 서명에 사용되는 Secret Key 정보는 민감한(sensitive) 정보이므로 시스템 환경 변수의 변수로 등록합니다.
- ${JWT_SECRET_KEY}는 단순한 문자열이 아니라 OS의 시스템 환경 변수의 값을 읽어오는 일종의 표현식입니다.
- 사용 중인 운영체제에 따라 아래 안내를 참고하여 환경변수를 설정합니다.
- access-token-expiration-minutes는 Access Token의 만료 시간이며, 30분으로 설정합니다.
- refresh-token-expiration-minutes는 Refresh Token의 만료 시간이며, 420분으로 설정합니다.
4️⃣ 로그인 인증 요청을 처리하는 Custom Security Filter 구현
이제 클라이언트의 로그인 인증 정보를 직접적으로 수신하여 인증 처리의 엔트리포인트(Entrypoint) 역할을 하는 Custom Filter를 구현해 봅시다.
package com.springboot.auth.filter;
import com.springboot.auth.dto.LoginDto;
import com.springboot.auth.jwt.JwtTokenizer;
import com.springboot.member.entity.Member;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.SneakyThrows;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.*;
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter { // (1)
private final AuthenticationManager authenticationManager;
private final JwtTokenizer jwtTokenizer;
// (2)
public JwtAuthenticationFilter(AuthenticationManager authenticationManager, JwtTokenizer jwtTokenizer) {
this.authenticationManager = authenticationManager;
this.jwtTokenizer = jwtTokenizer;
}
// (3)
@SneakyThrows
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {
ObjectMapper objectMapper = new ObjectMapper(); // (3-1)
LoginDto loginDto = objectMapper.readValue(request.getInputStream(), LoginDto.class); // (3-2)
// (3-3)
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());
return authenticationManager.authenticate(authenticationToken); // (3-4)
}
// (4)
@Override
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain,
Authentication authResult) {
Member member = (Member) authResult.getPrincipal(); // (4-1)
String accessToken = delegateAccessToken(member); // (4-2)
String refreshToken = delegateRefreshToken(member); // (4-3)
response.setHeader("Authorization", "Bearer " + accessToken); // (4-4)
response.setHeader("Refresh", refreshToken); // (4-5)
}
// (5)
private String delegateAccessToken(Member member) {
Map<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 base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
String accessToken = jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey);
return accessToken;
}
// (6)
private String delegateRefreshToken(Member member) {
String subject = member.getEmail();
Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getRefreshTokenExpirationMinutes());
String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
String refreshToken = jwtTokenizer.generateRefreshToken(subject, expiration, base64EncodedSecretKey);
return refreshToken;
}
}
5️⃣ Custom Filter 추가를 위한 SecurityConfiguration 설정 추가
Custom Filter인 JwtAuthenticationFilter의 구현이 끝났다면 JwtAuthenticationFilter를 Spring Security Filter Chain에 추가해서 로그인 인증을 처리하도록 해야 합니다.
SecurityConfiguration(V2)
package com.springboot.config;
import com.springboot.auth.filter.JwtAuthenticationFilter;
import com.springboot.auth.jwt.JwtTokenizer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import static org.springframework.security.config.Customizer.withDefaults;
@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()) // (1)
.and()
.authorizeHttpRequests(authorize -> authorize
.anyRequest().permitAll()
);
return http.build();
}
...
...
// (2)
public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> { // (2-1)
@Override
public void configure(HttpSecurity builder) throws Exception { // (2-2)
AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class); // (2-3)
JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager, jwtTokenizer); // (2-4)
jwtAuthenticationFilter.setFilterProcessesUrl("/v11/auth/login"); // (2-5)
builder.addFilter(jwtAuthenticationFilter); // (2-6)
}
}
}
로그인 인증 성공 및 실패에 따른 추가 처리
여러분이 이 정도까지만 구현하더라도 JWT 자격 검증을 위한 로그인 인증 기능을 사용하는 데 문제는 없습니다.
하지만 조금 더 나은 애플리케이션 구현을 위해 한 가지 기능만 더 추가해 보도록 하겠습니다.
Spring Security에서는 Username/Password 기반의 로그인 인증에 성공했을 때, 로그를 기록한다거나 로그인에 성공한 사용자 정보를 response로 전송하는 등의 추가 처리를 할 수 있는 핸들러(AuthenticationSuccessHandler)를 지원하며, 로그인 인증 실패 시에도 마찬가지로 인증 실패에 대해 추가 처리를 할 수 있는 핸들러(AuthenticationFailureHandler)를 지원합니다.
1️⃣ AuthenticationSuccessHandler 구현
package com.springboot.auth.handler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Slf4j
public class MemberAuthenticationSuccessHandler implements AuthenticationSuccessHandler { // (1)
// (2)
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException {
// 인증 성공 후, 로그를 기록하거나 사용자 정보를 response로 전송하는 등의 추가 작업을 할 수 있다.
log.info("# Authenticated successfully!");
}
}
코드 4-81은 로그인 인증 성공 시 추가 작업을 할 수 있는 MemberAuthenticationSuccessHandler 코드입니다.
코드의 설명은 다음과 같습니다.
- 우리가 직접 정의하는 Custom AuthenticationSuccessHandler는 (1)과 같이 AuthenticationSuccessHandler 인터페이스를 구현해야 합니다.
- AuthenticationSuccessHandler 인터페이스에는 onAuthenticationSuccess() 추상 메서드가 정의되어 있으며, onAuthenticationSuccess() 메서드를 구현해서 추가 처리를 하면 됩니다.
- (2)에서는 단순히 로그만 출력하고 있지만 💡 Authentication 객체에 사용자 정보를 얻은 후, HttpServletResponse로 출력 스트림을 생성하여 response를 전송할 수 있다는 사실을 기억하기 바랍니다.
2️⃣ AuthenticationFailureHandler 구현
package com.springboot.auth.handler;
import com.springboot.response.ErrorResponse;
import com.google.gson.Gson;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Slf4j
public class MemberAuthenticationFailureHandler implements AuthenticationFailureHandler { // (1)
@Override
public void onAuthenticationFailure(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception) throws IOException {
// 인증 실패 시, 에러 로그를 기록하거나 error response를 전송할 수 있다.
log.error("# Authentication failed: {}", exception.getMessage());
sendErrorResponse(response); // (2)
}
private void sendErrorResponse(HttpServletResponse response) throws IOException {
Gson gson = new Gson(); // (2-1)
ErrorResponse errorResponse = ErrorResponse.of(HttpStatus.UNAUTHORIZED); // (2-2)
response.setContentType(MediaType.APPLICATION_JSON_VALUE); // (2-3)
response.setStatus(HttpStatus.UNAUTHORIZED.value()); // (2-4)
response.getWriter().write(gson.toJson(errorResponse, ErrorResponse.class)); // (2-5)
}
}
코드 4-82는 로그인 인증 실패 시 추가 작업을 할 수 있는 MemberAuthenticationFailureHandler 코드입니다.
코드의 설명은 다음과 같습니다.
- 우리가 직접 정의하는 Custom AuthenticationFailureHandler는 (1)과 같이 AuthenticationFailureHandler 인터페이스를 구현해야 합니다.
- AuthenticationSuccessHandler 인터페이스에는 onAuthenticationFailure() 추상 메서드가 정의되어 있으며, onAuthenticationFailure() 메서드를 구현해서 추가 처리를 하면 됩니다.
- (2)에서는 바로 아래에 있는 sendErrorResponse() 메서드를 호출해 출력 스트림에 Error 정보를 담고 있습니다.
- (2-1)에서는 Error 정보가 담긴 객체(ErrorResponse)를 JSON 문자열로 변환하는 데 사용되는 Gson 라이브러리의 인스턴스를 생성합니다.
- (2-2)에서는 ErrorResponse 객체를 생성합니다. ErrorResponse.of() 메서드로 HttpStatus.UNAUTHORIZED 상태 코드를 전달합니다.ErrorResponse 클래스는 여러분들이 Spring MVC 섹션의 예외 처리 유닛에서 이미 사용해 본 조금은 익숙한 클래스입니다. ^^
- ErrorResponse 클래스가 기억나지 않는다면 [Spring MVC] 예외 처리 유닛을 참고하기 바랍니다.
- 💡 HttpStatus.UNAUTHORIZED(401) 상태 코드는 인증에 실패할 경우 전달할 수 있는 HTTP status라는 것을 기억하기 바랍니다.
- (2-3)에서는 response의 Content Type이 “application/json”이라는 것을 클라이언트에게 알려줄 수 있도록 MediaType.APPLICATION_JSON_VALUE를 HTTP Header에 추가합니다.
- (2-4)에서는 response의 status가 401 임을 클라이언트에게 알려줄 수 있도록 HttpStatus.UNAUTHORIZED.value()을 HTTP Header에 추가합니다.
- (2-5)에서는 Gson을 이용해 ErrorResponse 객체를 JSON 포맷 문자열로 변환 후, 출력 스트림을 생성합니다.
3️⃣ AuthenticationSuccessHandler와 AuthenticationFailureHandler 추가
이제 AuthenticationSuccessHandler 인터페이스와 AuthenticationFailureHandler 인터페이스의 구현 클래스를 JwtAuthenticationFilter에 등록하면 로그인 인증 시, 두 핸들러를 사용할 수 있습니다.
SecurityConfiguration(V3)
package com.springboot.config;
import com.springboot.auth.filter.JwtAuthenticationFilter;
import com.springboot.auth.jwt.JwtTokenizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import com.springboot.auth.handler.MemberAuthenticationFailureHandler;
import com.springboot.auth.handler.MemberAuthenticationSuccessHandler;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import static org.springframework.security.config.Customizer.withDefaults;
/**
* JwtAuthenticationFilter 추가
*/
@Configuration
public class SecurityConfiguration {
private final JwtTokenizer jwtTokenizer;
public SecurityConfiguration(JwtTokenizer jwtTokenizer) {
this.jwtTokenizer = jwtTokenizer;
}
...
...
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("/v11/auth/login");
jwtAuthenticationFilter.setAuthenticationSuccessHandler(new MemberAuthenticationSuccessHandler()); // (3) 추가
jwtAuthenticationFilter.setAuthenticationFailureHandler(new MemberAuthenticationFailureHandler()); // (4) 추가
builder.addFilter(jwtAuthenticationFilter);
}
}
}
AuthenticationSuccessHandler와 AuthenticationFailureHandler를 JwtAuthenticationFilter에 등록하는 방법은 어렵지 않습니다.
(3), (4)와 같이 JwtAuthenticationFilter에 등록해주기만 하면 됩니다.
"Spring에서는 객체를 생성할 때 new 키워드 사용을 자제하라고 했는데 new를 사용했네?"라고 의아해하는 분이 있을지도 모르겠습니다.
AuthenticationSuccessHandler와 AuthenticationFailureHandler 인터페이스의 구현 클래스가 다른 Security Filter에서 사용이 된다면 ApplicationContext에 Bean으로 등록해서 DI 받는 게 맞습니다.
💡 하지만 일반적으로 인증을 위한 Security Filter마다 AuthenticationSuccessHandler와 AuthenticationFailureHandler의 구현 클래스를 각각 생성할 것이므로 new 키워드를 사용해서 객체를 생성해도 무방합니다.
4️⃣ AuthenticationSuccessHandler 호출
AuthenticationSuccessHandler와 AuthenticationFailureHandler도 구현했고, jwtAuthenticationFilter에서 AuthenticationSuccessHandler와 AuthenticationFailureHandler를 사용할 수 있도록 SecurityConfiguration에 추가도 했습니다.
이제 우리가 jwtAuthenticationFilter에서 해당 핸들러의 구현 메서드를 호출해서 사용하기만 하면 됩니다.
JwtAuthenticationFilter(AuthenticationSuccessHandler 호출 코드 추가)
package com.springboot.auth.filter;
import com.springboot.auth.dto.LoginDto;
import com.springboot.auth.jwt.JwtTokenizer;
import com.springboot.member.entity.Member;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.SneakyThrows;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
...
...
@Override
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain,
Authentication authResult) throws ServletException, IOException {
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); // (1) 추가
}
...
...
}