Spring Boot - 소셜 로그인 + Redis

도비·2023년 12월 21일
0

Spring Boot

목록 보기
12/13
post-thumbnail

지난 글에서, 액세스 토큰을 클라이언트 측에서 전달받은 후 부터의 로그인 로직을 구현했다면, 이번 글에서는 인가 코드의 전달부터 서버 측에서 처리하는 로직을 구현하였다. 또, 리프레시 토큰까지 함께 반환하여 자동 로그인을 구현하였다.

플로우


1. 클라이언트는 redirect uri 를 통해 인가 코드를 받는다
2. 받은 인가코드를 클라이언트가 서버에 전송한다.
3. 서버는 카카오(제 3 인증기관)에 인가코드를 전송한다.
4. 이를 바탕으로 서버는 액세스 토큰을 받는다.
5. 서버는 받은 액세스 토큰으로 사용자 정보를 요청한다.
6. 사용자 정보를 자체 DB에 저장한 후 회원가입이 완료된다.
7. 서버 자체의 액세스 토큰과 리프레시 토큰을 발급해 클라이언트에 반환한다.

리프레시 토큰이 필요한 이유

서버에서는 로그인을 할 때 리프레시 토큰과 액세스 토큰을 반환한다. 액세스 토큰을 통해 인증을 하고 사용자를 식별한다. 그런데 왜 액세스 토큰 뿐 아니라 리프레시 토큰이 필요할까?
만약 액세스 토큰이 탈취당한다면, 서버는 탈취당한 사람과 탈취한 사람을 구분할 수 없기 때문에 문제가 생긴다. 그런데 HTTP 통신은 Stateless 한 방식을 따르므로 이 토큰을 가지고 있는 클라이언트가 정말 클라이언트 본인이 맞는지 확인할 수 없다.
그래서 액세스 토큰의 탈취문제를 다음처럼 해결한다

  • Access Token의 유효 기간을 짧게 설정한다.
  • Refresh Token의 유효 기간은 길게 설정한다.
  • 클라이언트는 Access Token과 Refresh Token을 둘 다 서버에 전송하여 전자로 인증하고 만료됐을 시 후자로 새로운 Access Token을 발급받는다.
  • 공격자는 Access Token을 탈취하더라도 짧은 유효 기간이 지나면 사용할 수 없다.
  • 정상적인 클라이언트는 유효 기간이 지나더라도 Refresh Token을 사용하여 새로운 Access Token을 생성, 사용할 수 있다.

그러면 우리는 사용자가 리프레시 토큰을 보내어 액세스 토큰의 재발급을 요청했을 때, 그 진위여부를 검증하려면 리프레시 토큰을 가지고 있어야 하기 때문에 서버가 접근이 가능한 저장소에 저장해놔야 한다. 저장할 수 있는 여러 선택지(RDB, NoSQL) 중에서 나는 NoSQL인 Redis를 선택했다.

Redis란?

Redis는 디스크가 아닌 메모리에 데이터를 저장하는 In-Memory 방식의 데이터베이스이다.

In-Memory?
In-Memory 데이터베이스는 MySQL과 같은 다른 일반 DB들처럼 SSD, HDD와 같은 보조기억장치가 아닌, 프로세서가 직접 액세스할 수 있는 컴퓨터의 주 메모리인 RAM에 데이터를 저장한다. RAM에 데이터를 저장하여 사용하게 되면 보조기억장치에서 데이터를 Load하는 비용이 절약된다. 때문에 인메모리 데이터베이스의 읽기 및 쓰기 연산은 기존 디스크 기반 데이터베이스보다 훨씬 빠르다.

Redis를 선택한 이유

  • RDB와는 다르게 데이터의 만료일을 지정할 수 있다. TTL(Time To Live)을 토큰의 만료일과 똑같이 맞춰두어 관리하면 토큰이 만료되면 Redis에서도 토큰이 삭제되도록 하여 데이터를 효율적으로 관리할 수 있다.
  • 짧은 시간에 만료되는 Access Token은 새롭게 갱신하기 위해 Refresh Token이 필요하다. 호출의 빈도가 높은 Refresh Token을 RDB에 저장하는 것보다 In-Memory DB에 저장해두고 사용하는 것이 속도가 빠르기 때문에 갱신 로직의 병목현상을 방지할 수 있다.

❗️ In-Memory DB는 휘발성 메모리이기 때문에 전원이 끊어지면 데이터가 전부 지워진다는 단점이 있다. -> 하지만 전원이 끊어지는 일을 자주 발생하지 않기 때문에 Redis를 선택했다.

코드

그러면 이제 구현을 진행해보자. 우선 앞서 설명했던 플로우에서 5번부터의 설명은 지난 글에서 확인할 수 있다.

Redis 사용하기

Redis를 사용하기 위해서는 컴퓨터에 Redis가 설치되어 있어야 한다.

설치 명령어

brew install redis

실행 명령어(foreground)

redis-server

build.gradle

먼저 의존성을 추가해주자.

implementation 'org.springframework.boot:spring-boot-starter-data-redis:2.3.1.RELEASE'

application.yml

spring:
  data:
    redis:
      host: localhost
      port: 6379

이렇게 설정 파일에 redis의 호스트와 포트를 지정해주고 다음 Configuration 파일에서 사용할 것이다.

RedisConfig.java

@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 을 적용해주기 위해 설정한다.

KakaoAuthApiClient.java

우리는 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(인가코드)를 담아 보낸다.

KakaoAccessTokenResponse.java

API 요청 후 받아오는 값이 들어있는 객체이다. 우리는 accessToken을 받아올 것이다.

@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record KakaoAccessTokenResponse(
        String accessToken
) {
    public static KakaoAccessTokenResponse of(
            final String accessToken
    ) {
        return new KakaoAccessTokenResponse(
                accessToken
        );
    }
}

KakaoSocialService.java

  • 요청 보내는 로직
    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를 보내 새로운 유저를 생성한다.

UserService.java

@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로 사용자 식별자 값을 찾아서 return
  • refreshToken : 리프레시 토큰으로 accessToken 재발급
  • isExistingUser : 소셜 계정 id로 이미 DB에 존재하는 사용자인지 확인
  • getTokenByUserId : userId를 기준으로 리프레시 토큰과 액세스 토큰 발급
  • deleteUser : 사용자 삭제

JwtTokenProvider.java

그러면 우리는 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에 저장해보자.

Token.java

@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 를 통해 기존 리프레시 토큰과 같은 만료일을 가지게 설정한다.

TokenRepository.java

public interface TokenRepository extends CrudRepository<Token, Long> {
    Optional<Token> findByRefreshToken(final String refreshToken);
}

RefreshTokenService.java

@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가 길어 비동기 처리 방식을 고민해봐야겠다! 🔥

profile
하루에 한 걸음씩

1개의 댓글

comment-user-thumbnail
2024년 5월 30일

정말 잘읽고 갑니다...!!

답글 달기