OAuth - JWT 적용해보기 - 2
OAuth + JWT - 2
클라이언트 요청 처리
클라이언트가 인증에 성공했을 때, 서버에서 사용자 정보를 OAuth 리소스 서버에 요청할 수 있다.
쿠키 관련 클래스 설계
public class CookieHandler {
public static void setRefreshTokenToHeader(HttpServletResponse response, String refreshToken, long expirationTime) {
ResponseCookie responseCookie = ResponseCookie
.from("refresh-token", refreshToken)
.maxAge(expirationTime)
.httpOnly(true)
.path("/")
.build();
response.setHeader(HttpHeaders.SET_COOKIE, responseCookie.toString());
}
}
리프레시 토큰을 쿠키로 응답하기 위해서 쿠키 관련 클래스를 설계하였다. 이때 쿠키의 만료 시간은 리프레시 토큰의 만료 시간과 동일하게 설정했다. 참고로 httpOnly()
메소드는 해당 쿠키가 JS를 통해서 접근할 수 없도록 하는 것이다. HTTPS를 사용하는 경우에만 쿠키를 주고받을 수 있는 secure(true)
설정도 있다.
서비스 수정
서비스에서 JwtUtil
의존성 주입을 통해 토큰을 만들고, 인증이 성공하면 사용자 번호를 이용해서 엑세스 토큰을 만든다.
@Service
@RequiredArgsConstructor
public class LoginService {
private final LoginRepository loginRepository;
private final ProviderFactory providerFactory;
private final JwtUtil jwtUtil;
public AuthInfoDto login(String code, String providerName) {
OAuthProvider provider = providerFactory.mapping(providerName);
AuthUserInfoDto authUserInfo = provider.authenticate(code);
User user = getOrCreateUser(authUserInfo);
UserToken userToken = jwtUtil.generateToken(String.valueOf(user.getId()));
return AuthInfoDto.convertFrom(user, userToken);
}
}
사용자 정보를 같이 반환해야 겠다고 생각한 이유는 사용자 정보의 갱신이 빈번하지 않기 때문이다. 만약 사용자 정보가 계속해서 변한는 서비스를 만들어야 한다면 토큰만 반환하고 클라이언트에서 매번 사용자 정보를 요청하도록 만들면 된다.
public record AuthInfoDto(
Long id,
String email,
String nickname,
UserToken userToken
) {
public static AuthInfoDto convertFrom(User user, UserToken userToken) {
return new AuthInfoDto(
user.getId(),
user.getEmail(),
user.getNickname(),
userToken
);
}
}
컨트롤러 수정
컨트롤러에서는 서비스로부터 받은 DTO를 이용해서 사용자 정보 + 엑세스 토큰을 메시지 바디에 담고, 리프레시 토큰을 메시지 헤더에 담아서 응답한다.
@RestController
@RequestMapping("/api/user")
@RequiredArgsConstructor
public class LoginController {
private final LoginService loginService;
@Value("${jwt.refresh-token-expiration-time}")
private long refreshTokenExpirationTime;
@PostMapping("/auth/{provider}")
public ResponseEntity<JSONResponse<LoginResponse>> login(
@PathVariable("provider") String providerName,
@RequestBody @Valid LoginRequest loginRequest,
HttpServletResponse response
) {
AuthInfoDto authInfo = loginService.login(loginRequest.code(), providerName);
CookieHandler.setRefreshTokenToHeader(response, authInfo.userToken().refreshToken(),
refreshTokenExpirationTime);
return ResponseEntity.ok()
.body(JSONResponse.onSuccess(
new LoginResponse(
authInfo.nickname(),
"Bearer",
authInfo.userToken().accessToken())
)
);
}
}
리프레시 토큰을 쿠키로 보낼 때 쿠키의 만료 시간을 설정하기 위해서 @Value
를 사용했다.
public record LoginResponse(
String name,
String tokenType,
String accessToken
) {
}
토큰으로 사용자 정보 조회
이제 토큰으로 사용자 정보를 조회하도록 하자. 엑세스 토큰은 헤더에, 리프레시 토큰은 쿠키를 통해서 요청 메시지가 오는 것을 기억하자.
서비스에 메소드 추가
@Service
@RequiredArgsConstructor
public class LoginService {
public UserInfoResponse getUserInfo(String accessToken, String refreshToken) {
Long userId = jwtUtil.getUserId(accessToken, refreshToken);
Optional<User> loginUserInfo = loginRepository.findById(userId);
return new UserInfoResponse(
loginUserInfo.orElseThrow(
() -> new UserNotFoundException(ErrorCode.USER_NOT_FOUND)).getNickname()
);
}
}
public record UserInfoResponse(
String name
) {
}
컨트롤러에 메소드 추가
어노테이션을 통해서 리프레시 토큰과 엑세스 토큰을 요청 메시지로부터 가져왔다.
@RestController
@RequestMapping("/api/user")
@RequiredArgsConstructor
public class LoginController {
@GetMapping("/info")
public ResponseEntity<JSONResponse<UserInfoResponse>> userInfo(
@CookieValue("refresh-token") String refreshToken,
@RequestHeader(value = "Authorization") String accessToken) {
return ResponseEntity.ok()
.body(
JSONResponse.onSuccess(loginService.getUserInfo(accessToken, refreshToken))
);
}
}
엑세스 토큰 재발급
리프레시 토큰을 이용해서 엑세스 토큰을 재발급 받도록 컨트롤러 및 서비스에 메소드를 추가한다.
서비스에 메소드 추가
@Service
@RequiredArgsConstructor
public class LoginService {
private final JwtUtil jwtUtil;
public AccessTokenResponse reissueAccessToken(String refreshToken) {
return new AccessTokenResponse(jwtUtil.regenerateAccessToken(refreshToken));
}
}
컨트롤러에 메소드 추가
@RestController
@RequestMapping("/api/user")
@RequiredArgsConstructor
public class LoginController {
@GetMapping("/token/reissue")
public ResponseEntity<JSONResponse<AccessTokenResponse>> reissue(
@CookieValue("refresh-token") String refreshToken) {
return ResponseEntity.ok()
.body(
JSONResponse.onSuccess(loginService.reissueAccessToken(refreshToken))
);
}
}
로그아웃 처리
로그아웃 처리를 할 때에는 리프레시 토큰 관련 쿠키의 키 값을 가지고 최대 수명을 0으로 만들어서 다시 응답하도록 하면 된다. 또한 임시 저장소에 저장된 리프레시 토큰을 삭제해야 한다.
서비스에 메소드 추가
@Service
@RequiredArgsConstructor
public class LoginService {
private final JwtUtil jwtUtil;
public void logout(String refreshToken) {
jwtUtil.cacheOutRefreshToken(refreshToken);
}
}
컨트롤러에 메소드 추가
@RestController
@RequestMapping("/api/user")
@RequiredArgsConstructor
public class LoginController {
@DeleteMapping("/logout")
public ResponseEntity<Object> logout(
@CookieValue("refresh-token") String refreshToken,
HttpServletResponse response
) {
CookieHandler.setRefreshTokenToHeader(response, "", 0L);
loginService.logout(refreshToken);
return ResponseEntity.noContent().build();
}
}
테스트
테스트를 위해서 클라이언트를 조금 수정했어야 했다. 변경된 코드는 첫 번째 포스팅에 첨부하였다.
사용자 정보 반환
인증이 완료된 이후 사용자 정보를 요청하면 화면에 다음과 같이 출력된다.
쿠키에 리프레시 토큰이 저장됐는지 살펴보자. 쿠키는 개발자 도구의 Application 탭에서 확인할 수 있다.
엑세스 토큰 재발급
잘 보면 이미 발급받은 엑세스 토큰과 재발급 받은 엑세스 토큰이 다른 것을 확인할 수 있다.
로그아웃 처리
로그아웃 버튼을 누르면 쿠키에 리프레시 토큰이 지워지는 지 확인해보자.
이것으로 사용자 인증 정보를 JWT로 관리할 수 있게 되었다. 하지만 현재의 어플리케이션은 값을 Map
자료구조로 저장하기 때문에 서버가 stateful하다. 따라서 서버를 stateless로 만들기 위해서 레디스를 적용해 볼 것이다.
댓글남기기