[Spring Boot] OAuth 개념부터 Spring Security + JWT + Open Feign을 활용한 카카오 소셜 로그인까지 올인원 패키지

황선웅·2023년 12월 4일

OAuth 란?

구글, 네이버, 카카오와 같은 다양한 플랫폼의 특정한 사용자 데이터에 접근하기 위해 제3자 클라이언트가 사용자의 접근 권한을 위임 받을 수 있는 표준 프로토콜이다.
즉, 우리의 서비스가 우리 서비스를 이용하는 유저의 타사 플랫폼 정보에 접근하기 위해 권한을 타사 플랫폼으로부터 위임 받는 것이다.

OAuth 2.0 구성 요소

  • Resource Owner
    • 리소스 소유자이다. 우리의 서비스를 이용하면서 구글, 네이버, 카카오 등의 플랫폼에 리소스를 소유하고 있는 사용자이다.
  • Authorization Server & Resource Server
    • Authorization Server는 리소스 소유자를 인증하고 Client에게 액세스 토큰을 발급해주는 서버이다.
    • Resource Server는 구글, 네이버, 카카오와 같이 리소스를 가지고 있는 서버이다.
    • Authorization Server와 Resource Server는 공식문서상 별개로 구분되어 있지만 서로 다른 서버로 구성할지 하나의 서버로 구성할지는 개발자가 선택하기 나름이라고 한다.
  • Client
    • Resource Server의 자원을 이용하고자 하는 서비스이다. 주로 우리가 개발하려는 서비스가 될 것이다.
      • ex) 인스타그램
    • Client는 우리가 개발하려는 서비스이므로 Resource Owner와 헷갈리지 말자.

OAuth 2.0 애플리케이션 등록

OAuth 2.0 서비스를 이용하기 전에는 선행되어야 하는 작업이 있다. Client를 Resource Server에 등록해야 하는 작업이다. 이때, Redirect URI를 등록해야 한다. Redirect URI는 사용자가 OAuth 2.0 서비스에서 인증을 마치고 사용자를 리다이렉션 시킬 위치이다.

  • Redirect URI
    • OAuth 2.0 서비스는 인증이 성공한 사용자를 사전에 등록한 Redirect URI로만 리다이렉션 시킨다. 승인되지 않은 URI로 리다이렉션 될 경우 Authorization Code를 중간에 탈취 당할 위험성이 있기 때문이다. 일부 OAuth 2.0 서비스는 여러 Redirect URI를 등록할 수 있다.
    • Redirect URI는 기본적으로 보안을 위해 https만 허용하지만 루프백(localhost)는 예외적으로 http가 허용된다.
  • Client Id, Client Secret
    • 등록 과정을 마치면 Client Id와 Client Secret를 얻을 수 있다. 발급된 Client Id와 Client Secret은 액세스 토큰을 획득하는데 사용된다. Client Id는 공개되어도 상관없지만 Client Secret은 절대 유출되면 안된다.

Authorization Code 란?
Client가 Access Token을 획득하기 위해 사용되는 임시 코드로 수명 시간이 1분 ~ 10분 정도로 매우 짧다.

OAuth 2.0 동작 메커니즘

  • 1 ~ 2 로그인 요청
    • Resource Owner가 우리 서비스를 통해 로그인을 요청한다. 이후 Client는 OAuth 프로세스를 시작하기 위해 사용자의 브라우저를 Authorization Server로 보내야 한다. Client는 이때 Authorization Server가 제공하는 Authorization URL에 response_type, client_id, redirect_uri, scope 등의 매개변수를 쿼리 스트링으로 포함하여 보낸다.
    • 예를 들어 어떤 OAuth 2.0 서비스의 Authorization URL이 https://authorization-server.com/auth 라면 결과적으로 Client는 아래와 같은 URL로 요청할 것이다.
      https://authorization-server.com/auth?response_type=code&client_id=1234&redirect_uri=https://example.com/callback&scope=create+delete
      • response_type: 반드시 code로 값을 설정해야 한다. 만약 인증이 성공할 경우 Client는 Authorization Code를 얻게 된다.
      • client_id: 애플리케이션을 생성했을 때 발급받은 Client Id
      • redirect_uri: 애플리케이션을 생성할 때 등록한 Redirect URI
      • scope: Client가 부여받은 리소스 접근 권한
  • 3 ~ 4 로그인 페이지 제공 & ID/PW 제공
    • Client가 요청한 Authorization URL로 이동된 Resource Owner는 제공된 로그인 페이지에서 id와 password 등을 입력하여 인증한다.
  • 5 ~ 6 Authorization Code 발급 & Redirect URI로 리다이렉트
    • 만약 인증이 성공하였다면 Authorization Server는 제공된 Redirect URI로 사용자를 리다이렉션 시킨다. 이때, Redirect URI에 Authorization Code를 포함하여 사용자를 리다이렉션 시킨다.
  • 7 ~ 8 Authorization Code와 Access Token 교환
    • Client는 Authorization Server에 Authorization Code를 전달하고 Access Token을 발급받는다. Client는 발급받은 Resource Owner의 Access Token을 저장하고 이후 Resource Server에서 Resource Owner의 리소스에 접근하기 위해 Access Token을 사용한다.
    • Access Token은 유출되어서는 안되기 때문에 https 프로토콜을 통해서만 사용될 수 있다.
    • Authorization Code와 Access Token 교환은 token 엔드 포인트에서 이루어지며 application/x-www-form-urlencoded의 형식에 맞춰 전달해야 한다.
      POST /oauth/token HTTP/1.1
      Host: authorization-server.com
      
      grant_type=authorization_code
      &code=xxxxxxxxxxx
      &redirect_uri=https://example.com/redirect
      $client_id=xxxxxxxxxx
      $client_secret=xxxxxxxxxx
      • grant_type: 항상 authorization_code로 설정되어야 한다.
      • code: 발급받은 Authorization Code
      • redirect_uri: Redirect URI
      • client_id: Client Id
      • client_secret: REC 표준상 필수는 아니지만 Client Secret이 발급된 경우에는 포함하여 요청해야 한다.
  • 9 로그인 성공
    • 1 ~ 8 과정을 성공적으로 마치면 Client는 Resource Owner에게 로그인이 성공하였음을 알린다.
  • 10 ~ 13 Access Token으로 리소스 접근
    • 이후 Resource Owner가 Resource Server의 리소스가 필요한 기능을 Client에 요청한다. Client는 위 과정에서 발급받고 저장해둔 Resource Owner의 Access Token을 사용하여 제한된 리소스에 접근하고 Resource Owner에게 자사의 서비스를 제공한다.

OAuth 2.0 스코프

OAuth 2.0은 스코프라는 개념을 통해서 유저 리소스에 대한 Client의 접근 범위를 제한할 수 있다.

스코프는 여러 개가 될 수 있으며 대소문자를 구분하는 문자열을 공백으로 구분하여 표현된다. 이때 문자열은 OAuth 2.0 인증 서버에 의해 정의된다.

그러면 OAuth 2.0의 동작 메커니즘 중 어떤 단계부터 서버에서 처리해야 할까?

OAuth에 대한 개념을 알아보았다. 그러면 OAuth 동작 메커니즘 1 ~ 13 중 서버가 처리해야 할 단계는 어디서 부터 어디일까?

플랫폼이 웹인지 앱인지에 따라 단계가 다를 수 있고 클라이언트에서 모두 처리하여 필요한 유저 정보만 서버로 넘겨주는 방식도 가능하다고 생각된다. 왜냐하면 iOS 또는 Android 플랫폼의 경우 각 플랫폼에서 지원하는 SDK를 사용하는 경우, Authorization Code를 발급 받는 과정이 내부적으로 구현되어 있고 최종적으로 Access Token을 받게 되어 자연스럽게 서버에서 처리해야 할 단계가 10 이후로 되기 때문이다. 반면에 웹의 경우 웹 클라이언트에서 Authorization Code를 발급 받고 이후 Access Token을 발급 받는 과정부터 서버에서 처리하도록 위임할 수 있게 된다. 따라서 플랫폼에 따라 서버가 처리해야 할 단계가 다를 수 있다고 보여진다. 그리고 사용자 경험과 보안적인 요소도 고려하여 단계를 설정할 수 있는데, 앞서서 명시한 것처럼 앱 플랫폼의 경우 각 플랫폼이 지원하는 SDK를 사용하지 않고 웹 뷰를 띄워 Authorization Code를 받도록 구현할 수도 있다. 다만 이러한 경우 앱을 사용하는 사용자는 소셜 로그인을 하기 위해 항상 웹 뷰를 거쳐야 돼서 앱 플랫폼에 최적화 되어 있는 SDK를 사용할 때보다 사용자 경험이 낮아질 수 있다. 반대로 앱 플랫폼이 권장하는 SDK를 사용하면 사용자 경험이 높아지지만 Authentication Code가 아닌 Access Token을 서버에 전달해야 하기 때문에 보안적으로 취약해질 수 있는 단점이 존재하게 된다.

코드로 가보자

코드 예시는 iOS 클라이언트와 협업했던 프로젝트에서 개발했던 코드이다. 해당 프로젝트에서는 iOS SDK를 사용하기로 결정하였기 때문에 서버는 1 ~ 13 중 10 이후의 단계를 처리하도록 구현하였다. 즉, iOS 클라이언트에서 카카오 플랫폼의 Access Token을 발급 받아 스프링 부트 서버로 전달하여 처리하는 흐름이다.

JWT

JWT 관련 기능을 담당하는 클래스는 발급, 생성, 검증 세 가지의 책임으로 나누어 구현하였다.

implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
implementation group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2'
implementation group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'

build.gradle에 의존성을 추가해준다.

jwt:
  secret: testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttest
  access-token-expire-time: 1800000 # 30분 밀리초
  refresh-token-expire-time: 604800000 # 1주 밀리초

application.yml에 jwt 관련 설정을 추가해준다. 우리 서비스에서는 access token의 유효 기간은 30분, refresh token의 유효 기간은 1주일로 설정하였다. 해당 유효 기간이 정답은 아니며 각 서비스에 주어진 요구사항과 애플리케이션의 사용성을 고려하여 적절한 기간을 선정하는 것이 중요하다.

@Builder(access = AccessLevel.PRIVATE)
public record Token(
        String accessToken,
        String refreshToken
) {
    public static Token of(String accessToken, String refreshToken) {
        return Token.builder()
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .build();
    }
}

클라이언트에 응답으로 반환할 JWT DTO 클래스이다.

@Component
public class JwtGenerator {
    @Value("${jwt.secret}")
    private String secretKey;
    @Value("${jwt.access-token-expire-time}")
    private long ACCESS_TOKEN_EXPIRE_TIME;
    @Value("${jwt.refresh-token-expire-time}")
    private long REFRESH_TOKEN_EXPIRE_TIME;

    public String generateToken(Long userId, boolean isAccessToken) {
        final Date now = generateNowDate();
        final Date expiration = generateExpirationDate(isAccessToken, now);
        return Jwts.builder()
                .setHeaderParam(Header.TYPE, Header.JWT_TYPE)
                .setSubject(String.valueOf(userId))
                .setIssuedAt(now)
                .setExpiration(expiration)
                .signWith(getSigningKey(), SignatureAlgorithm.HS256)
                .compact();
    }

    public JwtParser getJwtParser() {
        return Jwts.parserBuilder()
                .setSigningKey(getSigningKey())
                .build();
    }

    private Date generateNowDate() {
        return new Date();
    }

    private Date generateExpirationDate(boolean isAccessToken, Date now) {
        return new Date(now.getTime() + calculateExpireTime(isAccessToken));
    }

    private long calculateExpireTime(boolean isAccessToken) {
        if (isAccessToken) {
            return ACCESS_TOKEN_EXPIRE_TIME;
        }
        return REFRESH_TOKEN_EXPIRE_TIME;
    }

    private Key getSigningKey() {
        String encoded = Base64.getEncoder().encodeToString(secretKey.getBytes());
        return Keys.hmacShaKeyFor(encoded.getBytes());
    }
}

JwtGenerator 클래스는 userId(PK)를 Subject로 지정하여 access token과 refresh token을 생성한다.

@RequiredArgsConstructor
@Component
public class JwtProvider {
    private final JwtGenerator jwtGenerator;

    public Token issueToken(Long userId) {
        return Token.of(jwtGenerator.generateToken(userId, true),
                jwtGenerator.generateToken(userId, false));
    }

    public Long getSubject(String token) {
        JwtParser jwtParser = jwtGenerator.getJwtParser();
        return Long.valueOf(jwtParser.parseClaimsJws(token)
                .getBody()
                .getSubject());
    }
}

JwtProvider 클래스는 userId(PK)를 Subject로 지정한 access token과 refresh token을 발급하고 지정된 Subject를 조회한다.

@RequiredArgsConstructor
@Component
public class JwtValidator {
    private final JwtGenerator jwtGenerator;

    public void validateAccessToken(String accessToken) {
        try {
            JwtParser jwtParser = jwtGenerator.getJwtParser();
            jwtParser.parseClaimsJws(accessToken);
        } catch (ExpiredJwtException e) {
            throw new UnauthorizedException(ErrorStatus.EXPIRED_ACCESS_TOKEN);
        } catch (Exception e) {
            throw new UnauthorizedException(ErrorStatus.INVALID_ACCESS_TOKEN_VALUE);
        }
    }

    public void validateRefreshToken(String refreshToken) {
        try {
            JwtParser jwtParser = jwtGenerator.getJwtParser();
            jwtParser.parseClaimsJws(refreshToken);
        } catch (ExpiredJwtException e) {
            throw new UnauthorizedException(ErrorStatus.EXPIRED_REFRESH_TOKEN);
        } catch (Exception e) {
            throw new UnauthorizedException(ErrorStatus.INVALID_REFRESH_TOKEN_VALUE);
        }
    }

    public void equalsRefreshToken(String refreshToken, String storedRefreshToken) {
        if (!refreshToken.equals(storedRefreshToken)) {
            throw new UnauthorizedException(ErrorStatus.NOT_MATCH_REFRESH_TOKEN);
        }
    }
}

JwtValidator 클래스는 발급된 access token과 refresh token을 검증한다.

Spring Security

JWT 유효성 검증과 사용자 인증을 위해 Spring Security의 Security Filter, Authentication을 활용하였다.

implementation 'org.springframework.boot:spring-boot-starter-security'

build.gradle에 의존성을 추가해준다.

public class UserAuthentication extends UsernamePasswordAuthenticationToken {
    private UserAuthentication(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
        super(principal, credentials, authorities);
    }

    public static UserAuthentication createDefaultUserAuthentication(Long userId) {
        return new UserAuthentication(userId, null, null);
    }
}

UserAuthentication 클래스는 사용자 정보를 저장한 인증의 주체이다. JWT를 사용하기 때문에 userId(PK)만 principal로 저장하여 사용하였다.

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtValidator jwtValidator;
    private final JwtProvider jwtProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        final String accessToken = getAccessToken(request);
        jwtValidator.validateAccessToken(accessToken);
        setAuthentication(request, jwtProvider.getSubject(accessToken));
        filterChain.doFilter(request, response);
    }

    private String getAccessToken(HttpServletRequest request) {
        String accessToken = request.getHeader(Constants.AUTHORIZATION);
        if (StringUtils.hasText(accessToken) && accessToken.startsWith(Constants.BEARER)) {
            return accessToken.substring(Constants.BEARER.length());
        }
        throw new UnauthorizedException(ErrorStatus.INVALID_ACCESS_TOKEN);
    }

    private void setAuthentication(HttpServletRequest request, Long userId) {
        UserAuthentication authentication = createDefaultUserAuthentication(userId);
        createWebAuthenticationDetailsAndSet(request, authentication);
        SecurityContext securityContext = SecurityContextHolder.getContext();
        securityContext.setAuthentication(authentication);
    }

    private void createWebAuthenticationDetailsAndSet(HttpServletRequest request, UserAuthentication authentication) {
        WebAuthenticationDetailsSource webAuthenticationDetailsSource = new WebAuthenticationDetailsSource();
        WebAuthenticationDetails webAuthenticationDetails = webAuthenticationDetailsSource.buildDetails(request);
        authentication.setDetails(webAuthenticationDetails);
    }
}

JwtAuthenticationFilter 클래스는 요청으로 들어온 access token의 유효성 검증, 사용자 인증을 담당한다.

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
    private final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
        handleException(response);
    }

    private void handleException(HttpServletResponse response) throws IOException {
        setResponse(response, HttpStatus.UNAUTHORIZED, ErrorStatus.UNAUTHORIZED);
    }

    private void setResponse(HttpServletResponse response, HttpStatus httpStatus, ErrorStatus errorStatus) throws IOException {
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding(Constants.CHARACTER_TYPE);
        response.setStatus(httpStatus.value());
        PrintWriter writer = response.getWriter();
        writer.write(objectMapper.writeValueAsString(ApiResponse.of(errorStatus)));
    }
}

JwtAuthenticationEntryPoint 클래스는 JwtAuthenticationFilter에서 사용자 인증에 실패한 경우 발생하는 401 예외를 핸들링한다.

public class ExceptionHandlerFilter extends OncePerRequestFilter {
    private final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException {
        try {
            filterChain.doFilter(request, response);
        } catch (UnauthorizedException e) {
            handleUnauthorizedException(response, e);
        } catch (Exception ee) {
            handleException(response);
        }
    }

    private void handleUnauthorizedException(HttpServletResponse response, Exception e) throws IOException {
        UnauthorizedException ue = (UnauthorizedException) e;
        ErrorStatus errorStatus = ue.getErrorStatus();
        HttpStatus httpStatus = errorStatus.getHttpStatus();
        setResponse(response, httpStatus, errorStatus);
    }

    private void handleException(HttpServletResponse response) throws IOException {
        setResponse(response, HttpStatus.INTERNAL_SERVER_ERROR, ErrorStatus.INTERNAL_SERVER_ERROR);
    }

    private void setResponse(HttpServletResponse response, HttpStatus httpStatus, ErrorStatus errorStatus) throws IOException {
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding(Constants.CHARACTER_TYPE);
        response.setStatus(httpStatus.value());
        PrintWriter writer = response.getWriter();
        writer.write(objectMapper.writeValueAsString(ApiResponse.of(errorStatus)));
    }
}

ExceptionHandlerFilter 클래스는 JwtAuthenticationFilter에서 요청으로 들어온 access token의 유효성 검증에 실패한 경우 발생하는 401 예외를 핸들링한다.

@RequiredArgsConstructor
@EnableWebSecurity
@Configuration
public class SecurityConfig {
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtProvider jwtProvider;
    private final JwtValidator jwtValidator;
    private static final String[] whiteList = {"/api/user/signin", "/api/user/signup", "/api/user/reissue", "/"};

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return web -> web.ignoring().requestMatchers(whiteList);
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                .csrf(AbstractHttpConfigurer::disable)
                .formLogin(AbstractHttpConfigurer::disable)
                .httpBasic(AbstractHttpConfigurer::disable)
                .sessionManagement(sessionManagementConfigurer ->
                        sessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .exceptionHandling(exceptionHandlingConfigurer ->
                        exceptionHandlingConfigurer.authenticationEntryPoint(jwtAuthenticationEntryPoint))
                .authorizeHttpRequests(authorizationManagerRequestMatcherRegistry ->
                        authorizationManagerRequestMatcherRegistry.anyRequest().authenticated())
                .addFilterBefore(new JwtAuthenticationFilter(jwtValidator, jwtProvider), UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(new ExceptionHandlerFilter(), JwtAuthenticationFilter.class)
                .build();
    }
}

WebSecurity

WebSecurityCustomizer를 통해 Spring Security를 적용하지 않을 whiteList를 설정한다.

HttpSecurity

SecurityFilterChain에 ExceptionHandlerFilter, JwtAuthenticationFilter, JwtAuthenticationEntryPoint 클래스를 등록하고 추가적으로 보안 설정을 한다.

  • csrf (Cross Site Request Forgery)
    • 세션 기반 인증이 아닌 JWT 기반 인증을 활용하기 때문에 스프링 부트 서버는 사용자 인증 정보를 보관하지 않는 stateless를 유지하게 된다. 따라서 csrf 설정은 불필요하기 때문에 disable로 설정하였다.
  • formLogin
    • JWT 기반 인증을 활용하기 때문에 form 로그인 방식은 disable로 설정하였다.
  • httpBasic
    • JWT 기반 인증을 활용하기 때문에 http 기본 인증 방식은 disable로 설정하였다.
  • SessionCreationPolicy.STATELESS
    • JWT 기반 인증을 활용하기 때문에 SessionCreationPolicy를 stateless로 설정하였다. stateless로 설정하면 Spring Security에서 세션을 사용하지 않게 된다.

Open Feign

우선 Open Feign의 개념을 알아보자. Open Feign은 넷플릭스에 의해 처음 만들어진 선언적인 HTTP Client 도구로 외부 API 호출을 쉽게 할 수 있도록 도와주는 라이브러리이다. 이런 Open Feign은 다음과 같은 장점을 가지고 있다.

  • 인터페이스와 어노테이션 기반으로 동작하기 때문에 보일러 플레이트 코드가 최소화된다.
  • Spring MVC 어노테이션을 사용할 수 있다.
  • 다른 Spring Cloud 기술들과의 통합이 쉽다.

즉, Open Feign은 간단히 말해 Spring Data JPA 처럼 인터페이스와 어노테이션으로 특정 동작을 명시하기만 하면 구현이 되어 편리하게 개발을 할 수 있게 된다.

그러면 왜 HTTP Client를 사용했을까?

iOS 클라이언트로부터 전달 받은 카카오 Access Token으로 해당 사용자 정보를 조회하기 위해 스프링 부트 서버에서 카카오 서버로 서버 to 서버 통신을 해야한다. 이때, API 통신을 하기 위한 도구로서 Open Feign 라이브러리를 사용한 것이다.

implementation 'org.springframework.cloud:spring-cloud-starter-openfeign:4.0.3'

build.gradle에 의존성을 추가해준다.

@EnableFeignClients(basePackageClasses = Application.class)
@Configuration
public class FeignClientConfig {
}

@EnableFeignClients 어노테이션을 적용하면 Open Feign을 활성화할 수 있다.

카카오 토큰 정보 보기 API
[Kakao Developers] 카카오 토큰 정보 보기 API

스프링 부트 서버에서 카카오에서 제공하는 Open API 중, 카카오 토큰 정보 보기 API를 사용하여 iOS 클라이언트로부터 전달 받은 카카오 Access Token의 정보를 가져온다. 응답으로 받은 해당 카카오 Access Token의 소유자의 정보 중 회원번호를 우리 서비스의 회원 고유 식별값으로 설정하여 로그인 처리를 한다. 이때 꼭 회원번호를 회원 고유 식별값으로 사용해야 하는 것은 아니며 각 서비스에 주어진 요구사항을 고려하여 적절한 값을 선정하면 된다. 만약, 카카오 로그인뿐만 아니라 애플 로그인 등 한 서비스에서 여러가지 소셜 로그인을 제공해야 한다면 회원번호 + 플랫폼 문자열을 조합해서 회원 고유 식별값으로 사용하면 될 것이다.

@FeignClient(name = "kakao-feign-client", url = "https://kapi.kakao.com/")
public interface KakaoFeignClient {
    @GetMapping("v1/user/access_token_info")
    KakaoAccessTokenInfo getKakaoAccessTokenInfo(@RequestHeader("Authorization") String accessToken);
}

@FeignClient 어노테이션으로 API 통신을 수행할 클라이언트를 지정한다. 이때, name과 url 속성을 필수로 지정해야 하는데, name은 원하는 이름을 자유롭게 지으면 되고 url은 API를 호출할 대상의 url을 기입하면 된다. 우리 서비스는 카카오 토큰 정보 보기 API를 호출할 것이기 때문에 해당 API의 url을 기입하였다.

@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
public class KakaoAccessToken {
    private static final String TOKEN_TYPE = "Bearer ";
    private String accessToken;

    public static KakaoAccessToken createKakaoAccessToken(String accessToken) {
        return new KakaoAccessToken(accessToken);
    }

    public String getAccessTokenWithTokenType() {
        return TOKEN_TYPE + accessToken;
    }
}

카카오 서버에 요청으로 보낼 DTO 클래스이다. 매개변수로 전달 받은 카카오 Access Token은 Bearer 문자열이 포함되어 있지 않기 때문에 getAccessTokenWithTokenType 메서드를 구현하여 Bearer 문자열이 포함되도록 기능을 추가하였다.

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class KakaoAccessTokenInfo {
    private Long id;
}

카카오 서버로부터 응답으로 받을 DTO 클래스이다. 우리 서비스는 회원번호만 필요하기 때문에 id 속성만 명시하였다.

@RequiredArgsConstructor
@Component
public class KakaoOAuthProvider {
    private final KakaoFeignClient kakaoFeignClient;

    public String getKakaoPlatformId(String accessToken) {
        KakaoAccessToken kakaoAccessToken = createKakaoAccessToken(accessToken);
        String accessTokenWithTokenType = kakaoAccessToken.getAccessTokenWithTokenType();
        KakaoAccessTokenInfo kakaoAccessTokenInfo = getKakaoAccessTokenInfo(accessTokenWithTokenType);
        return String.valueOf(kakaoAccessTokenInfo.getId());
    }

    private KakaoAccessTokenInfo getKakaoAccessTokenInfo(String accessTokenWithTokenType) {
        try {
            return kakaoFeignClient.getKakaoAccessTokenInfo(accessTokenWithTokenType);
        } catch (FeignException e) {
            throw new UnauthorizedException(ErrorStatus.INVALID_KAKAO_ACCESS_TOKEN);
        }
    }
}

KakaoOAuthProvider 클래스는 스프링 부트 서버에서 카카오 서버로 서버 to 서버 통신을 수행하며 iOS 클라이언트로부터 전달 받은 카카오 Access Token의 정보 중 회원번호 값을 가져와서 반환한다.

Service

카카오 소셜 회원가입, 카카오 소셜 로그인, JWT 재발급, 로그아웃, 회원탈퇴 기능의 비즈니스 로직을 구현하였다.

@RequiredArgsConstructor
@Transactional
@Service
public class AuthService {
    private final JwtProvider jwtProvider;
	private final JwtValidator jwtValidator;
    private final KakaoOAuthProvider kakaoOAuthProvider;
	private final UserRepository userRepository;

    public UserAuthResponseDto signIn(String token) {
        String platformId = kakaoOAuthProvider.getKakaoPlatformId(token);
        User findUser = getUser(platformId);
        Token issuedToken = issueAccessTokenAndRefreshToken(findUser);
        updateRefreshToken(findUser, issuedToken.getRefreshToken());
        return UserAuthResponseDto.of(issuedToken, findUser);
    }

    public UserAuthResponseDto signUp(String token, UserSignUpRequestDto request) {
        validateDuplicateNickname(request.nickname());
        String platformId = kakaoOAuthProvider.getKakaoPlatformId(token);
        validateDuplicateUser(platformId);
        User user = createUser(platformId, request.nickname());
        User savedUser = userRepository.save(user);
        Token issuedToken = issueAccessTokenAndRefreshToken(savedUser);
        updateRefreshToken(savedUser, issuedToken.getRefreshToken());
        return UserAuthResponseDto.of(issuedToken, savedUser);
    }

    @Transactional(noRollbackFor = UnauthorizedException.class)
    public Token reissue(String refreshToken, UserReissueRequestDto request) {
        Long userId = request.userId();
        User findUser = getUser(userId);
        validateRefreshToken(userId, refreshToken, findUser.getRefreshToken());
        Token issuedToken = issueAccessTokenAndRefreshToken(findUser);
        updateRefreshToken(findUser, issuedToken.getRefreshToken());
        return issuedToken;
    }

    public void signOut(Long userId) {
        User findUser = getUser(userId);
        deleteRefreshToken(findUser);
    }

    public void withdraw(Long userId) {
        userRepository.deleteById(userId);
    }

    private User getUser(String platformId) {
        return userRepository.findUserByPlatformId(platformId)
                .orElseThrow(() -> new EntityNotFoundException(ErrorStatus.USER_NOT_FOUND));
    }

    private void validateDuplicateNickname(String nickname) {
        if (userRepository.existsUserByNickname(nickname)) {
            throw new ConflictException(ErrorStatus.DUPLICATE_NICKNAME);
        }
    }

    private void validateDuplicateUser(String platformId) {
        if (userRepository.existsUserByPlatformId(platformId)) {
            throw new ConflictException(ErrorStatus.DUPLICATE_USER);
        }
    }

    private void validateRefreshToken(Long userId, String refreshToken, String storedRefreshToken) {
        try {
            jwtValidator.validateRefreshToken(refreshToken);
            jwtValidator.equalsRefreshToken(refreshToken, storedRefreshToken);
        } catch (UnauthorizedException e) {
            signOut(userId);
            throw e;
        }
    }

    private Token issueAccessTokenAndRefreshToken(User user) {
        return jwtProvider.issueToken(user.getId());
    }

    private void updateRefreshToken(User user, String refreshToken) {
        user.updateRefreshToken(refreshToken);
    }

    private User getUser(Long userId) {
        return userRepository.findById(userId)
                .orElseThrow(() -> new EntityNotFoundException(ErrorStatus.USER_NOT_FOUND));
    }

    private void deleteRefreshToken(User findUser) {
        findUser.updateRefreshToken(null);
    }
}

카카오 소셜 회원가입

  1. 닉네임 중복 여부를 검증한다.
  2. iOS 클라이언트로부터 전달 받은 카카오 Access Token의 회원번호 값을 가져온다.
  3. 회원번호 값으로 중복 회원 여부를 검증한다.
  4. 회원가입을 진행한다.
  5. 회원 PK 값으로 access token과 refresh token을 발급한다.
  6. 회원 정보에 발급된 refresh token을 저장한다. (최적화를 위해 Redis를 사용하는 것이 좋다.)
  7. 발급된 access token과 refresh token을 반환한다.

카카오 소셜 로그인

  1. iOS 클라이언트로부터 전달 받은 카카오 Access Token의 회원번호 값을 가져온다.
  2. 회원번호 값으로 회원을 조회한다. 이때, 비회원이라면 예외가 발생한다.
  3. 회원 PK 값으로 access token과 refresh token을 발급한다.
  4. 회원 정보에 발급된 refresh token을 저장한다. (최적화를 위해 Redis를 사용하는 것이 좋다.)
  5. 발급된 access token과 refresh token을 반환한다.

JWT 재발급

  1. 회원 PK 값으로 회원을 조회한다.
  2. 조회한 회원 정보에 저장된 refresh token과 iOS 클라이언트로부터 전달 받은 refresh token을 비교하여 일치하는지 검증한다. 이때, 검증에 실패하면 로그아웃을 진행한다.
  3. 회원 PK 값으로 access token과 refresh token을 발급한다.
  4. 회원 정보에 발급된 refresh token을 저장한다. (최적화를 위해 Redis를 사용하는 것이 좋다.)
  5. 발급된 access token과 refresh token을 반환한다.

로그아웃

  1. 회원 PK 값으로 회원을 조회한다.
  2. 회원 정보에 저장된 refresh token을 삭제한다.

회원탈퇴

  1. 회원 PK 값으로 회원을 삭제한다. (회원탈퇴 정책은 요구사항에 따라 다를 수 있다. 간단한 예시를 위해 회원 정보를 삭제하는 기능으로 구현하였다.)

Error

예외는 RuntimeException을 상속 받은 BusinessException을 부모로 각 Http Status에 따라 BusinessException을 한번 더 상속받는 구조로 설계하여 처리하였다. 또한 동일한 Http Status에 서로 다른 예외 케이스를 정의하기 위해 예외 메시지를 관리하는 Enum 클래스를 추가적으로 활용하였다.

@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
public enum ErrorStatus {
    /**
     * 401 Unauthorized
     */
    UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "리소스 접근 권한이 없습니다."),
    INVALID_KAKAO_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "카카오 액세스 토큰의 정보를 조회하는 과정에서 오류가 발생하였습니다."),
    INVALID_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "액세스 토큰의 형식이 올바르지 않습니다. Bearer 타입을 확인해 주세요."),
    INVALID_ACCESS_TOKEN_VALUE(HttpStatus.UNAUTHORIZED, "액세스 토큰의 값이 올바르지 않습니다."),
    EXPIRED_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "액세스 토큰이 만료되었습니다. 재발급 받아주세요."),
    INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "리프레시 토큰의 형식이 올바르지 않습니다."),
    INVALID_REFRESH_TOKEN_VALUE(HttpStatus.UNAUTHORIZED, "리프레시 토큰의 값이 올바르지 않습니다."),
    EXPIRED_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "리프레시 토큰이 만료되었습니다. 다시 로그인해 주세요."),
    NOT_MATCH_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "일치하지 않는 리프레시 토큰입니다."),

    /**
     * 404 Not Found
     */
    USER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 회원입니다."),

    /**
     * 409 Conflict
     */
    DUPLICATE_NICKNAME(HttpStatus.CONFLICT, "이미 존재하는 닉네임입니다."),
    DUPLICATE_USER(HttpStatus.CONFLICT, "이미 존재하는 회원입니다."),

    /**
     * 500 Internal Server Error
     */
    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류입니다.");

    private final HttpStatus httpStatus;
    private final String message;
}

ErrorStatus 클래스는 각 Http Status에 따른 예외 메시지를 Enum으로 관리한다.

@Getter
public class BusinessException extends RuntimeException {
    private final ErrorStatus errorStatus;

    public BusinessException(ErrorStatus errorStatus) {
        super(errorStatus.getMessage());
        this.errorStatus = errorStatus;
    }
}

BusinessException 클래스는 RuntimeException을 상속 받은 unchecked 예외이다. 비즈니스 로직에서 발생하게 되는 예외 케이스에서 활용하며 각 Http Status에 따라 BusinessException을 한번 더 상속받은 커스텀 예외 클래스들이 사용된다.

public class UnauthorizedException extends BusinessException {
    public UnauthorizedException() {
        super(ErrorStatus.UNAUTHORIZED);
    }

    public UnauthorizedException(ErrorStatus errorStatus) {
        super(errorStatus);
    }
}

UnauthorizedException 클래스는 BusinessException을 한번 더 상속받은 커스텀 예외 클래스들 중 한 가지이며 비즈니스 로직에서 발생하게 되는 예외 케이스들 중 401 Unauthorized 예외 케이스에서 사용된다.

결론

OAuth의 개념부터 JWT 인증을 활용하기 위해 Spring Security의 어떤 Component를 활용하고 어떻게 보안 설정을 하는지 작성해보았다. 또한 자체 회원가입과 로그인 서비스를 제공하지 않고 소셜 플랫폼 중 카카오 플랫폼을 통해 간편하게 회원가입, 로그인 하는 방법을 알아보았다. 아이디와 비밀번호를 입력하지 않고 카카오 플랫폼에서 제공하는 사용자의 고유 값으로 회원가입, 로그인을 하기 위해 필요한 사용자 정보는 무엇이고 이를 획득하기 위해 서버 to 서버 통신이 이루어져야 하는 점과 이를 위해 사용된 Http Client 도구인 Open Feign을 알아보았다. 이 글은 소셜 로그인을 할 수 있는 많은 방법 중 한 가지 방법일 뿐이다. 핵심은 주어진 요구사항을 잘 파악하고 이를 해결하는 과정에 집중하는 것이 중요할 것 같다. 해결하는 과정 속에서 다양한 접근 방법을 시도해보며 어떤 방법이 현재 우리 서비스에서 가장 좋은 방법일지 고민해보고 선택하는게 가장 중요하지 않을까 싶다.

0개의 댓글