GitHub - da9dac/blank
Contribute to da9dac/blank development by creating an account on GitHub.
github.com
전체 코드는 해당 깃허브 레포지토리에서 볼 수 있습니다.
이번 게시글에서는
액세스 토큰과 리프래시 토큰 발급,
리프래시 토큰을 이용한 액세스 토큰 재발급,
액세스 토큰 검증
이렇게 3개의 필터를 구현하겠다.
로그인 처리 및 토큰 발급 필터
사용자의 로그인 요청을 받아
서버에 저장된 인증 정보와 일치하는지 판단하여
사용자의 로그인을 처리하고
성공 시 액세스 토큰과 리프래시 토큰을 발급하는
필터를 구현하겠다.
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final JwtProvider jwtProvider;
private final AuthenticationManager authenticationManager;
public JwtAuthenticationFilter(JwtProvider jwtProvider, AuthenticationManager authenticationManager1) {
this.jwtProvider = jwtProvider;
this.authenticationManager = authenticationManager1;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
return null;
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response,
FilterChain chain, Authentication authResult) throws IOException, ServletException {
}
}
우선 UsernamePasswordAuthenticationFilter를 상속받는 필터 클래스를 만들어준다.
토큰을 발급받기 위해 JwtProvider와
인증을 수행하기 위한
AuthenticationManager 객체를 DI 받는다.
attemptAuthentication와 successfulAuthentication 메서드를
오버라이딩 하여 인증을 처리하고 토큰을 발급하는
로직을 추가해주면 된다.
@SneakyThrows
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (!request.getMethod().equals("POST")) {
// POST 메서드로만 로그인 요청을 받고 싶은 경우
// 다른 메서드로 들어온 요청일 시 예외를 발생
}
try {
JwtLogin loginDto = getLoginDtoFrom(request);
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginDto.getEmail(), loginDto.getPassword());
return authenticationManager.authenticate(authenticationToken);
} catch (Exception exception) {
return null;
}
}
메서드 명에서 알 수 있듯이
인증을 시도하는 기능을 수행하는 메서드다.
로그인 요청을 POST 메서드만 허용하고 싶다면
위와 같이 설정하면 되고
모두 허용할거라면 넘어가도 상관없다.
로그인 요청 객체의 정보로
미인증 상태의 UsernamePasswordAuthenticationToken을 생성하고
이를 AuthenticationManager에 전달한다.
인증에 성공하면 인증 상태의 토큰을 리턴하고
인증에 실패하면 예외가 발생할 것이다.
(발생한 예외는 try - catch 문으로 처리해 준다)
지금은 코드를 설명하기 위해
예외 처리를 대충 넘어가지만
어떤 이유로 인증에 실패했는지
자세하게 처리하는 것이 좋다.
private JwtLogin getLoginDtoFrom(HttpServletRequest request) throws IOException {
return new ObjectMapper().readValue(request.getInputStream(), JwtLogin.class);
}
요청 객체에서 로그인 요청 데이터를 얻어올 수 있다.
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response,
FilterChain chain, Authentication authResult) throws IOException {
String accessToken = jwtProvider.createAccessToken(authResult, ACCESS_TOKEN_EXPIRE_TIME);
String refreshToken = jwtProvider.createRefreshToken(authResult, REFRESH_TOKEN_EXPIRE_TIME);
response.setHeader(AUTHORIZATION, BEARER + accessToken);
response.setHeader(REFRESH, BEARER + refreshToken);
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.getWriter().write("로그인 성공");
}
마지막으로 인증에 성공한 경우 자동으로 실행될 메서드를 구현한다.
인증에 성공했으니 응답 헤더에 포함할
액세스 토큰과 리프래시 토큰을 생성하고
이를 응답의 헤더에 지정해 준다.
토큰의 형태는 "Bearer abcde12345"처럼 생겼기 때문에
꼭 Bearer을 붙여줘야 한다.
헷갈릴 위험이 있기 때문에 이런 값들은
상수에 저장해 두는 것이 좋다.
이제 로그인에 성공하면 이 메서드에서 설정한
응답들이 헤더와 바디에 표시될 것이다.
회원가입 구현 및 로그인 테스트
로그인 요청을 보내기 위해 간단하게 회원가입 기능을 구현하겠다.
@RestController
@RequestMapping("/auth")
public class AuthController {
private MemberRepository memberRepository;
private PasswordEncoder passwordEncoder;
public AuthController(MemberRepository memberRepository, PasswordEncoder passwordEncoder) {
this.memberRepository = memberRepository;
this.passwordEncoder = passwordEncoder;
}
@PostMapping("/signup")
public ResponseEntity<Void> signup() {
memberRepository.save(
Member.builder()
.email("test@email.com")
.password(passwordEncoder.encode("qwer1234!"))
.authority(Authority.ROLE_USER)
.build()
);
return new ResponseEntity<>(HttpStatus.CREATED);
}
}
원래는 서비스 로직이라 컨트롤러단에 두면 안 되지만
빠르게 로그인 테스트만 하기 위해
컨트롤러에서 처리하게 했다.
요청을 보낸 후에 로그인을 해보면
위와 같이 액세스 토큰과 리프래시 토큰이
헤더에 담겨 있는 걸 확인할 수 있다.
액세스 토큰 재발급 구현
액세스 토큰이 만료된 경우
리프래시 토큰으로 재발급받는 방법은
간단하게 두 가지 방법이 있다.
첫 번째는 클라이언트에게 매 요청마다
액세스 토큰과 리프래시 토큰을 모두 받아
액세스 토큰이 만료된 경우
자동으로 리프래시 토큰을 발급해 주는 방법이다.
두 번째는 요청에 필요한 액세스 토큰만 받고
만료된 액세스 토큰인 경우
클라이언트에게 리프래시 토큰을 요청한다.
이번에는 두 번째 방법을 사용해 보겠다.
public class JwtRefreshFilter extends OncePerRequestFilter {
private JwtProvider jwtProvider;
public JwtRefreshFilter(JwtProvider jwtProvider) {
this.jwtProvider = jwtProvider;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
}
}
위와 같이
OncePerRequestFilter를 상속받는 필터를 만들어준다.
doFilterInternal 메서드에
리프래시 토큰으로 액세스 토큰을 발급해주는
로직을 구현해 주면 된다.
private String getRefreshToken(HttpServletRequest request) {
String refreshToken = request.getHeader(REFRESH);
if (refreshToken == null) {
throw new JwtNotFoundException();
}
return refreshToken.replace(BEARER, "");
}
우선 사용자가 보낸 요청 헤더에서
리프래시 토큰을 얻어 온다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
if(!request.getMethod().equals("POST")){
// 특정 메서드로만 요청 받고 싶은 경우
}
else{
try {
String refreshToken = getRefreshToken(request);
jwtProvider.validateToken(refreshToken);
String refilledAccessToken =
jwtProvider.refillAccessToken(refreshToken, ACCESS_TOKEN_EXPIRE_TIME);
response.setHeader(AUTHORIZATION, BEARER + refilledAccessToken);
} catch (Exception exception) {
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "알 수 없는 오류입니다. 다시 시도해주세요.");
}
}
}
그 후에 이전에 구현했던 인증 필터와 비슷한 구조로
실질적인 필터의 로직을 구현해 준다.
헤더에서 가져온 리프래시 토큰이
올바른 토큰인지 검증한 후에
올바르다면 액세스 토큰을 새로 발급받아
응답의 헤더에 지정해 준다.
이 과정에서 발생하는 예외는
계속 언급하지만 좀 더 세세하게 처리해 주는 것이 좋다.
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !request.getRequestURI().equals(REFRESH_URL);
}
추가로 shouldNotFilter 메서드도 오버라이딩 하여
특정 주소의 요청만 허용하게 해 주겠다.
(리프래시 요청을 받고 싶은 URL을 지정해 주면 된다)
액세스 토큰 재발급 테스트
이제 리프래시 토큰을 보내
액세스 토큰이 재발급되는지 확인해 보겠다.
액세스 토큰만 재발급하는 작업에 성공했다.
액세스 토큰 검증 구현
이제 토큰을 발급하는 필터들의 구현은 모두 끝났으니
인증 및 인가가 필요한 요청에 대해
요청에 포함된 액세스 토큰을 검증하고
사용자의 권한이 충분한지
검증하는 필터를 구현하겠다.
public class JwtVerificationFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
private final CustomUserDetailsService customUserDetailsService;
public JwtVerificationFilter(JwtProvider jwtProvider, CustomUserDetailsService customUserDetailsService) {
this.jwtProvider = jwtProvider;
this.customUserDetailsService = customUserDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
}
}
검증 필터도 마찬가지로
OncePerRequestFilter를 상속받아 구현하겠다.
해당 필터는 토큰을 생성하는 것이 아닌
검증만 수행하면 된다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try{
Claims claims = verifyClaims(request);
setAuthenticationToContext(claims);
} catch(Exception e){
request.setAttribute(EXCEPTION, e);
}
filterChain.doFilter(request, response);
}
해당 필터는 위와 같이 토큰의 Claim만 검증하면
올바른 토큰인걸 확인할 수 있으니
검증이 통과하면 시큐리티 콘텍스트에
인증 정보를 등록해 주면 된다.
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String accessToken = getAccessToken(request);
return accessToken == null || !accessToken.startsWith(BEARER);
}
요청 헤더에 액세스 토큰이 포함되지 않은 경우는
해당 메서드에서 처리해 준다.
private Claims verifyClaims(HttpServletRequest request) {
String accessToken = getAccessToken(request).replace(BEARER, "");
return jwtProvider.getClaims(accessToken);
}
Claim 검증은 위와 같이
기존에 JwtProvider 클래스에 구현해 둔
토큰 검증 메서드를 사용한다.
private String getAccessToken(HttpServletRequest request) {
return request.getHeader(AUTHORIZATION);
}
액세스 토큰은 위와 같이
요청의 헤더에서 간단하게 가져올 수 있다.
private void setAuthenticationToContext(Claims claims) {
CustomUserDetails principal =
(CustomUserDetails) customUserDetailsService.loadUserByUsername(claims.getSubject());
Collection<? extends GrantedAuthority> authorities = principal.getAuthorities();
Authentication authentication =
new UsernamePasswordAuthenticationToken(principal, null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
콘텍스트에 인증 정보를 저장하는 메서드로
토큰의 Claim에서 사용자의 이메일을 가져와
CustomUserDetails를 얻어온 후에
이를 이용해 해당 사용자의 권한을 포함한
인증 객체를 만들고 컨텍스트에 저장한다.
검증 필터 테스트
이제 로그인한 사용자만 요청할 수 있는
API를 새로 만든 후에
액세스 토큰을 제대로 검증하는지
요청을 보내보겠다.
@GetMapping("/test")
public String test() {
return "테스트 성공";
}
우선 위와 같이 간단한 API를 만들어준다.
static Customizer<AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry> getAuthorizeHttpRequestsConfigurer() {
return auth -> auth
.requestMatchers(
new AntPathRequestMatcher("/auth/test")
).hasRole("USER")
.anyRequest().permitAll();
}
해당 API를 로그인한 사용자만
요청을 보낼 수 있게 설정해 준다.
우선 헤더에 액세스 토큰을 포함하지 않으니
당연히 권한이 없다고 요청에 실패한다.
이제 위와 같이 액세스 토큰을 포함하여
요청을 보내면 성공하는 걸 볼 수 있다.
마치며
기존 프로젝트에서는
스프링 부트 2.7.1 / 스프링 시큐리티 5.7.1
버전을 사용했다.
이번에는 각각 3.1.3 / 6.1.3 버전을 사용했는데
설정 부분에서 달라진 점들이 있고
Deprecate 된 메서드들을 사용하지 않고 하려니
예상치 못한 오류들이 발생하여 애를 먹었다.
Deprecate 된 and 메서드를 사용을 자제하고
apply 메서드를 사용해서
커스텀한 설정을 적용하려니
필터 체인 빌드를 할 수가 없어
apply를 따로 빼서 적용시켰다.
글을 정리하는 요령이 없어서
다른 블로그들의 정리글에 비해
양이 많은거 같긴하다...
어쨌든 JWT 구현 정리를 모두 마쳤으니
OAuth 인증 방식에 대해서도 준비해 보겠다.
'Back-End > Security' 카테고리의 다른 글
OAuth2.0 인증 구현하기 (0) | 2023.09.24 |
---|---|
JWT 로컬 로그인 구현하기 - 1편 (0) | 2023.09.21 |
[스프링 시큐리티] 공식문서로 배워보기 : Username/Password - 2 (0) | 2023.08.15 |
[스프링 시큐리티] 공식문서로 배워보기 : Username/Password - 1 (0) | 2023.08.14 |
[스프링 시큐리티] 공식문서로 배워보기 : 인증 아키텍처 (0) | 2023.08.14 |