지난 글에서, 액세스 토큰을 클라이언트 측에서 전달받은 후 부터의 로그인 로직을 구현했다면, 이번 글에서는 인가 코드의 전달부터 서버 측에서 처리하는 로직을 구현하였다. 또, 리프레시 토큰까지 함께 반환하여 자동 로그인을 구현하였다.
1. 클라이언트는 redirect uri 를 통해 인가 코드를 받는다
2. 받은 인가코드를 클라이언트가 서버에 전송한다.
3. 서버는 카카오(제 3 인증기관)에 인가코드를 전송한다.
4. 이를 바탕으로 서버는 액세스 토큰을 받는다.
5. 서버는 받은 액세스 토큰으로 사용자 정보를 요청한다.
6. 사용자 정보를 자체 DB에 저장한 후 회원가입이 완료된다.
7. 서버 자체의 액세스 토큰과 리프레시 토큰을 발급해 클라이언트에 반환한다.
서버에서는 로그인을 할 때 리프레시 토큰과 액세스 토큰을 반환한다. 액세스 토큰을 통해 인증을 하고 사용자를 식별한다. 그런데 왜 액세스 토큰 뿐 아니라 리프레시 토큰이 필요할까?
만약 액세스 토큰이 탈취당한다면, 서버는 탈취당한 사람과 탈취한 사람을 구분할 수 없기 때문에 문제가 생긴다. 그런데 HTTP 통신은 Stateless 한 방식을 따르므로 이 토큰을 가지고 있는 클라이언트가 정말 클라이언트 본인이 맞는지 확인할 수 없다.
그래서 액세스 토큰의 탈취문제를 다음처럼 해결한다
그러면 우리는 사용자가 리프레시 토큰을 보내어 액세스 토큰의 재발급을 요청했을 때, 그 진위여부를 검증하려면 리프레시 토큰을 가지고 있어야 하기 때문에 서버가 접근이 가능한 저장소에 저장해놔야 한다. 저장할 수 있는 여러 선택지(RDB, NoSQL) 중에서 나는 NoSQL인 Redis를 선택했다.
Redis는 디스크가 아닌 메모리에 데이터를 저장하는 In-Memory 방식의 데이터베이스이다.
In-Memory?
In-Memory 데이터베이스는 MySQL과 같은 다른 일반 DB들처럼 SSD, HDD와 같은 보조기억장치가 아닌, 프로세서가 직접 액세스할 수 있는 컴퓨터의 주 메모리인 RAM에 데이터를 저장한다. RAM에 데이터를 저장하여 사용하게 되면 보조기억장치에서 데이터를 Load하는 비용이 절약된다. 때문에 인메모리 데이터베이스의 읽기 및 쓰기 연산은 기존 디스크 기반 데이터베이스보다 훨씬 빠르다.
❗️ In-Memory DB는 휘발성 메모리이기 때문에 전원이 끊어지면 데이터가 전부 지워진다는 단점이 있다. -> 하지만 전원이 끊어지는 일을 자주 발생하지 않기 때문에 Redis를 선택했다.
그러면 이제 구현을 진행해보자. 우선 앞서 설명했던 플로우에서 5번부터의 설명은 지난 글에서 확인할 수 있다.
Redis를 사용하기 위해서는 컴퓨터에 Redis가 설치되어 있어야 한다.
brew install redis
redis-server
먼저 의존성을 추가해주자.
implementation 'org.springframework.boot:spring-boot-starter-data-redis:2.3.1.RELEASE'
spring:
data:
redis:
host: localhost
port: 6379
이렇게 설정 파일에 redis의 호스트와 포트를 지정해주고 다음 Configuration 파일에서 사용할 것이다.
@Configuration
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String host;
@Value("${spring.data.redis.port}")
private int port;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(host, port);
}
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
return redisTemplate;
}
}
RedisTemplate 에 LettuceConnectionFactory 을 적용해주기 위해 설정한다.
우리는 Kakao API에 인가 코드를 보내 Kakao에 액세스 토큰을 받는 인터페이스를 만들어주어야 한다.
@FeignClient(name = "kakaoApiClient", url = "https://kauth.kakao.com")
public interface KakaoAuthApiClient {
@PostMapping(value = "/oauth/token", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
KakaoAccessTokenResponse getOAuth2AccessToken(
@RequestParam("grant_type") String grantType,
@RequestParam("client_id") String clientId,
@RequestParam("redirect_uri") String redirectUri,
@RequestParam("code") String code
);
}
카카오에 요청을 보낼 때, grant_type (여기선 "authorization_code")과 client_id (일전에 발급받았던 REST API ID), 그리고 redirect_uri, 그리고 code(인가코드)를 담아 보낸다.
API 요청 후 받아오는 값이 들어있는 객체이다. 우리는 accessToken을 받아올 것이다.
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record KakaoAccessTokenResponse(
String accessToken
) {
public static KakaoAccessTokenResponse of(
final String accessToken
) {
return new KakaoAccessTokenResponse(
accessToken
);
}
}
private static final String AUTH_CODE = "authorization_code";
private static final String REDIRECT_URI = "http://localhost:8080/kakao/callback";
@Value("${kakao.clientId}")
private String clientId;
private final UserService userService;
private final KakaoApiClient kakaoApiClient;
private final KakaoAuthApiClient kakaoAuthApiClient;
@Transactional
@Override
public LoginSuccessResponse login(final String authorizationCode) {
String accessToken;
try {
// 인가 코드로 Access Token + Refresh Token 받아오기
accessToken = getOAuth2Authentication(authorizationCode);
} catch (FeignException e) {
//만료되었을 경우
throw new BadRequestException(ErrorMessage.AUTHENTICATION_CODE_EXPIRED);
}
// Access Token으로 유저 정보 불러오기
return getUserInfo(accessToken);
}
private String getOAuth2Authentication(
final String authorizationCode
) {
KakaoAccessTokenResponse tokenResponse = kakaoAuthApiClient.getOAuth2AccessToken(
AUTH_CODE,
clientId,
REDIRECT_URI,
authorizationCode
);
return tokenResponse.accessToken();
}
private LoginSuccessResponse getUserInfo(
final String accessToken
) {
KakaoUserResponse userResponse = kakaoApiClient.getUserInformation("Bearer " + accessToken);
return getTokenDto(userResponse);
}
private LoginSuccessResponse getTokenDto(
final KakaoUserResponse userResponse
) {
if(userService.isExistingUser(userResponse.id())){
return userService.getTokenByUserId(userService.getIdBySocialId(userResponse.id()));
}else {
return userService.getTokenByUserId(userService.createUser(userResponse));
}
}
UserService에 socialId를 보내 이미 존재하는 유저인지 확인하고, 아닐 경우 userResponse를 보내 새로운 유저를 생성한다.
@Service
@RequiredArgsConstructor
public class UserService {
private final JwtTokenProvider jwtTokenProvider;
private final UserRepository userRepository;
private final RefreshTokenService refreshTokenService;
public Long createUser(final KakaoUserResponse userResponse) {
User user = User.of(
userResponse.kakaoAccount().profile().nickname(),
userResponse.kakaoAccount().profile().profileImageUrl(),
userResponse.kakaoAccount().profile().accountEmail(),
userResponse.id()
);
return userRepository.save(user).getId();
}
public Long getIdBySocialId(
final Long socialId
) {
User user = userRepository.findBySocialId(socialId).orElseThrow(
() -> new NotFoundException(ErrorMessage.USER_NOT_FOUND)
);
return user.getId();
}
public AccessTokenGetSuccess refreshToken(
final String refreshToken
) {
Long userId = jwtTokenProvider.getUserFromJwt(refreshToken);
if (!userId.equals(refreshTokenService.findIdByRefreshToken(refreshToken))) {
throw new UnAuthorizedException(ErrorMessage.TOKEN_INCORRECT_ERROR);
}
UserAuthentication userAuthentication = new UserAuthentication(userId, null, null);
return new AccessTokenGetSuccess(
jwtTokenProvider.issueAccessToken(userAuthentication)
);
}
public boolean isExistingUser(
final Long socialId
) {
return userRepository.findBySocialId(socialId).isPresent();
}
public LoginSuccessResponse getTokenByUserId(
final Long id
) {
UserAuthentication userAuthentication = new UserAuthentication(id, null, null);
String refreshToken = jwtTokenProvider.issueRefreshToken(userAuthentication);
refreshTokenService.saveRefreshToken(id, refreshToken);
return LoginSuccessResponse.of(
jwtTokenProvider.issueAccessToken(userAuthentication),
refreshToken
);
}
@Transactional
public void deleteUser(
final Long id
) {
User user = userRepository.findById(id)
.orElseThrow(
() -> new NotFoundException(ErrorMessage.USER_NOT_FOUND)
);
userRepository.delete(user);
}
}
createUser
: User Entity를 생성하고 저장getIdBySocialId
: 사용자의 소셜 계정 id로 사용자 식별자 값을 찾아서 returnrefreshToken
: 리프레시 토큰으로 accessToken 재발급isExistingUser
: 소셜 계정 id로 이미 DB에 존재하는 사용자인지 확인getTokenByUserId
: userId를 기준으로 리프레시 토큰과 액세스 토큰 발급deleteUser
: 사용자 삭제그러면 우리는 JwtTokenProvider에서 리프레시 토큰을 새롭게 발급해주는 로직을 작성해야 한다.
public String issueRefreshToken(final Authentication authentication) {
return issueToken(authentication, REFRESH_TOKEN_EXPIRATION_TIME);
}
private String issueToken(
final Authentication authentication,
final Long expiredTime
) {
final Date now = new Date();
final Claims claims = Jwts.claims()
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + expiredTime)); // 만료 시간 설정
claims.put(MEMBER_ID, authentication.getPrincipal());
return Jwts.builder()
.setHeaderParam(Header.TYPE, Header.JWT_TYPE) // Header
.setClaims(claims) // Claim
.signWith(getSigningKey()) // Signature
.compact();
}
이렇게 발급된 Refresh 토큰을 Redis에 저장해보자.
@RedisHash(value = "refreshToken", timeToLive = 60 * 60 * 24 * 1000L * 14)
@AllArgsConstructor
@Getter
@Builder
public class Token {
@Id
private Long id;
private String refreshToken;
public static Token of(
final Long id,
final String refreshToken
) {
return Token.builder()
.id(id)
.refreshToken(refreshToken)
.build();
}
}
@RedisHash
를 통해 기존 리프레시 토큰과 같은 만료일을 가지게 설정한다.
public interface TokenRepository extends CrudRepository<Token, Long> {
Optional<Token> findByRefreshToken(final String refreshToken);
}
@RequiredArgsConstructor
@Service
public class RefreshTokenService {
private final RedisTemplate<String, Object> redisTemplate;
private final TokenRepository tokenRepository;
@Transactional
public void saveRefreshToken(
final Long userId,
final String refreshToken
) {
tokenRepository.save(
Token.of(
userId,
refreshToken
)
);
}
public Long findIdByRefreshToken(
final String refreshToken
) {
Token token = tokenRepository.findByRefreshToken(refreshToken)
.orElseThrow(
() -> new NotFoundException(ErrorMessage.REFRESH_TOKEN_NOT_FOUND)
);
return token.getId();
}
//삭제 로직
@Transactional
public void deleteRefreshToken(
final Long userId
) {
Token token = tokenRepository.findById(userId).orElseThrow(
() -> new NotFoundException(ErrorMessage.REFRESH_TOKEN_NOT_FOUND)
);
tokenRepository.delete(token);
}
}
이렇게 구현이 마치고 나면 인가 코드로 요청을 보냈을 때 다음과 같이 결과를 받을 수 있다.
생각보다 외부 API를 호출할 때의 latency가 길어 비동기 처리 방식을 고민해봐야겠다! 🔥
정말 잘읽고 갑니다...!!