이번에는 refresh token을 가지고 access token을 발급해 보겠습니다.
SignService 추가
SignService.java
먼저 SignService 클래스에 refresh token 값을 인자로 받았을 때 access token을 반환해주는 로직을 추가하겠습니다.
public RefreshTokenResponse refreshToken(String rToken) {
validateRefreshToken(rToken); // refresh token 검증
String subject = tokenService.extractRefreshTokenSubject(rToken); // subject 추출
String accessToken = tokenService.createAccessToken(subject); //subject를 가지고 access token 생성
return new RefreshTokenResponse(accessToken);
}
private void validateRefreshToken(String rToken) {
if(!tokenService.validateRefreshToken(rToken)) {
throw new AuthenticationEntryPointException();
}
}
순서는 다음과 같습니다.
- refresh token 검증
- refresh token으로부터 subject 추출
- subject를 가지고 access token 생성
RefreshTokenResponse.java
반환을 위해 dto 페키지에 RefreshTokenResponse.java를 생성하겠습니다.
@Data
@AllArgsConstructor
public class RefreshTokenResponse {
private String accessToken;
}
SignService 테스트
정상적인 케이스 & 비정상적인 케이스
@Test
void refreshTokenTest() {
// given
String refreshToken = "refreshToken";
String subject = "subject";
String accessToken = "accessToken";
given(tokenService.validateRefreshToken(refreshToken)).willReturn(true);
given(tokenService.extractRefreshTokenSubject(refreshToken)).willReturn(subject);
given(tokenService.createAccessToken(subject)).willReturn(accessToken);
// when
RefreshTokenResponse res = signService.refreshToken(refreshToken);
// then
assertThat(res.getAccessToken()).isEqualTo(accessToken);
}
@Test
void refreshTokenExceptionByInvalidTokenTest() {
// given
String refreshToken = "refreshToken";
given(tokenService.validateRefreshToken(refreshToken)).willReturn(false);
// when, then
assertThatThrownBy(() -> signService.refreshToken(refreshToken))
.isInstanceOf(AuthenticationEntryPointException.class);
}
SignController 추가
이제 API요청을 처리할 수 있는 메서드를 SignController에 추가하겠습니다.
@PostMapping("/api/refresh-token")
@ResponseStatus(HttpStatus.OK)
//@RequestHeader : 헤더에 포함된 Authorization 토큰 값 추출
public Response refreshToken(@RequestHeader(value = "Authorization") String refreshToken) {
return success(signService.refreshToken(refreshToken));
}
Securityconfig에 /api/refresh-token 요청 인가 추가
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.httpBasic().disable()
.formLogin().disable()
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers(HttpMethod.POST, "/api/sign-in", "/api/sign-up").permitAll()
.antMatchers(HttpMethod.POST, "/api/sign-in", "/api/sign-up", "/api/refresh-token").permitAll() // /api/refresh-token 추가
.antMatchers(HttpMethod.GET, "/api/**").permitAll()
.antMatchers(HttpMethod.DELETE, "/api/members/{id}/**").access("@memberGuard.check(#id)")
.anyRequest().hasAnyRole("ADMIN")
.and()
.exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler())
.and()
.exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint())
.and()
.addFilterBefore(
new JwtAuthenticationFilter(tokenService, userDetailsService)
, UsernamePasswordAuthenticationFilter.class
);
}
/api/refresh-token 테스트
@Test
void refreshTokenTest() throws Exception {
// given
given(signService.refreshToken("refreshToken")).willReturn(createRefreshTokenResponse("accessToken"));
// when, then
mockMvc.perform(
post("/api/refresh-token")
.header("Authorization", "refreshToken"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.result.data.accessToken").value("accessToken"));
}
SignControllerAdvice 테스트
@Test
void refreshTokenAuthenticationEntryPointException() throws Exception { // 1
// given
given(signService.refreshToken(anyString())).willThrow(AuthenticationEntryPointException.class);
// when, then
mockMvc.perform(
post("/api/refresh-token")
.header("Authorization", "refreshToken"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(-1001));
}
@Test
void refreshTokenMissingRequestHeaderException() throws Exception { // 2
// given, when, then
mockMvc.perform(
post("/api/refresh-token"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(-1009));
}
Spring Security
이제 POST /api/refresth-token 요청에 access token을 생성했습니다. 이전과 달리 이제는 access token과 refresh token 모두 접근할 수 있습니다. 이에 따른 Spring Security 검증 로직을 수정하겠습니다.
JwtAuthenticationFilter.java
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
private final TokenService tokenService;
private final CustomUserDetailsService userDetailsService;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
String token = extractToken(request); //JWT 토큰 값 추출
if (validateToken(token)) { // 유효성 검사
setAuthentication(token); // 사용자 정보 저장
}
chain.doFilter(request, response); // 요청을 다음 filter로 전달
}
private String extractToken(ServletRequest request) {
return ((HttpServletRequest) request).getHeader("Authorization");
}
private boolean validateToken(String token) {
return token != null && tokenService.validateAccessToken(token);
}
private void setAuthentication(String token) {
String userId = tokenService.extractAccessTokenSubject(token);
CustomUserDetails userDetails = userDetailsService.loadUserByUsername(userId);
SecurityContextHolder.getContext().setAuthentication(new CustomAuthenticationToken(userDetails, userDetails.getAuthorities()));
}
}
CustomAuthenticationToken.java
JwtAuthenticationFilter 클래스의 setAuthentication 메서드에서 인증 정보를 저장하는 객체인 CustomAuthenticationToken을 생성해보겠습니다.
public class CustomAuthenticationToken extends AbstractAuthenticationToken {
private CustomUserDetails principal;
public CustomAuthenticationToken(CustomUserDetails principal, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal; //사용자 정보
setAuthenticated(true);
}
@Override
public CustomUserDetails getPrincipal() {
return principal;
}
@Override
public Object getCredentials() {
throw new UnsupportedOperationException();
}
}
AuthHelper.java
토큰에 따른 사용자 인증이 불필요하여 삭제합니다.
@Component
@Slf4j
public class AuthHelper {
public boolean isAuthenticated() {
return getAuthentication() instanceof CustomAuthenticationToken &&
getAuthentication().isAuthenticated();
}
public Long extractMemberId() {
return Long.valueOf(getUserDetails().getUserId());
}
public Set<RoleType> extractMemberRoles() {
return getUserDetails().getAuthorities()
.stream()
.map(authority -> authority.getAuthority())
.map(strAuth -> RoleType.valueOf(strAuth))
.collect(Collectors.toSet());
}
//public boolean isAccessTokenType() {
// log.info("isAccessTokenType");
// return "access".equals(((CustomAuthenticationToken) getAuthentication()).getType());
//}
//public boolean isRefreshTokenType() {
// return "refresh".equals(((CustomAuthenticationToken) getAuthentication()).getType());
//}
private CustomUserDetails getUserDetails() {
return (CustomUserDetails) getAuthentication().getPrincipal();
}
private Authentication getAuthentication() {
return SecurityContextHolder.getContext().getAuthentication();
}
}
MemberGuard.java
MemberGuard에서도 토큰 타입에 따른 검증을 삭제하겠습니다.
@Component
@RequiredArgsConstructor
@Slf4j
public class MemberGuard {
private final AuthHelper authHelper;
public boolean check(Long id) {
//return authHelper.isAuthenticated()
// && authHelper.isAccessTokenType()
// && hasAuthority(id);
return authHelper.isAuthenticated() && hasAuthority(id);
}
private boolean hasAuthority(Long id) {
Long memberId = authHelper.extractMemberId();
Set<RoleType> memberRoles = authHelper.extractMemberRoles();
return id.equals(memberId) || memberRoles.contains(RoleType.ROLE_ADMIN);
}
}
테스트 수정
Spring Security 로직 수정을 하고나면 테스트 하나가 실패합니다.
@Test
void deleteAccessDeniedByRefreshTokenTest() throws Exception {
// given
Member member = memberRepository.findByEmail(initDB.getMember1Email()).orElseThrow(MemberNotFoundException::new);
SignInResponse signInRes = signService.signIn(createSignInRequest(initDB.getMember1Email(), initDB.getPassword()));
// when, then
mockMvc.perform(
delete("/api/members/{id}", member.getId()).header("Authorization", signInRes.getRefreshToken()))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/exception/entry-point"));
}
기존에는 accesstoken으로만 요청을 처리할 수 있다고 테스트를 작성하였기에 refresh token 으로 delete 처리를 할 때 기대값을 CustomAccessDeniedHandler를 설정했습니다. 하지만 수정된 로직으로 인해 더이상 인증되지 않기 때문에 기대 값을 CustomAuthenticationEntryPointHandler로 수정 하였습니다.
API 확인
이번에는 Postman에서 API 요청을 해서 로직이 정상적으로 작동하는지 확인 해 보겠습니다.
POST /api/sign-in
POST /api/refresh-token 토큰 값 없을 때
POST /api/refresh-token 토큰 값 있을 때
궁금한신점이나 잘못된 부분이 있으면 자유롭게 댓글 달아주세요.
'spring > 게시판 api' 카테고리의 다른 글
Spring boot 게시판 API 서버 제작 (12) - 로그인 - api 문서 만들기 (1) | 2023.02.23 |
---|---|
Spring boot 게시판 API 서버 제작 (11) - 로그인 - Token 코드 리팩토링 (0) | 2023.02.23 |
Spring boot 게시판 API 서버 제작 (10) - 로그인 - 인증 로직 테스트 (0) | 2023.02.21 |
Spring boot 게시판 API 서버 제작 (9) - 로그인 - 인증 로직 (0) | 2023.02.20 |
Spring boot 게시판 API 서버 제작 (8) - 로그인 - 조회, 삭제 로직 (0) | 2023.02.19 |