Spring Security - JWT + 카카오 소셜 로그인 구현

도비·2023년 12월 19일
1

Spring Boot

목록 보기
11/13
post-thumbnail

프로젝트를 진행하다보면 소셜 로그인을 구현할 일이 생긴다.

소셜 로그인 구현에는 두가지 방법이 있는데,

  • 클라이언트 측에서 서버 측으로 제 3자 서비스에 접근할 수 있는 인가 코드를 준다.
    - 그 인가코드로 서버 측에서 제 3자 서비스에서 액세스 토큰과 리프레시 토큰을 받아온다. 그 후 액세스 토큰으로 제 3자 서비스에서 사용자 정보를 받아온 뒤, DB에 저장한다.
  • 클라이언트 측에서 서버 측으로 제 3자 서비스에서 받아온 액세스 토큰과 리프레시 토큰을 받아온다.
    - 그 토큰으로 사용자는 제 3자 서비스에서 사용자 정보를 받아온 뒤, DB에 저장한다.

웹 서비스의 경우 인가 코드부터, 앱 서비스의 경우 액세스토큰부터 서버가 처리한다.
이 글에선 우선 액세스 토큰부터 처리하는 로직을 구현했고, 사용자를 저장한 뒤 서버 자체의 jwt를 생성해 accessToken을 넘겨주었다. 그리고 그 accessToken을 통해 api를 접속할 수 있게 된다. 만일 accessToken이 만료될 경우, refreshToken으로 accessToken을 요청한다. refreshToken 부분은 추후에 블로그로 쓸 예정이다.

플로우는 다음과 같다.

소셜 로그인 플로우

카카오에서 액세스 토큰 받아오기

클라이언트에게 액세스 토큰을 받을 수 있는 상황이 되기 전까지는, 스스로 테스트 해봐야한다.
그러기 위해선 Kakao Developers 에 구현하여 앱을 만들어야 한다.
앱 이름이나, 사용자 명은 자유로 정하면된다.

앱을 만들면 REST API 키를 비롯한 네 가지 키가 나온다!
그리고 왼쪽 창에 동의항목에 들어가면

위와 같이 동의할 수 있는 목록이 뜨고, 나는 이메일 동의를 추가해주었다.

우리가 테스트 해보기 위해 필요한 것은 카카오 인가 코드, 그리고 인가 코드로 받은 accessToken이다.

그러기 위해선 카카오 로그인 탭에서 카카오 로그인을 활성화 해준 후, Redirect-uri를 설정해줘야 한다.
아무것이나 설정해주어도 되지만, 우선 Spring Boot가 돌아가고 있는 http://localhost:8080/kakao/callback을 사용하였다.

카카오에 인가코드 요청을 보내는 url은 다음과 같다
https://kauth.kakao.com/oauth/authorize?client_id={REST_API 앱 키}&response_type=code&redirect_uri={redirectURI}

이 url을 입력하면 동의 요청창과 함께 상위 Url에 http://localhost:8080/kakao/callback?code={인가코드} 이렇게 나타나 있을 것이다.

우리는 이 인가코트에 요청을 보내 액세스 토큰을 받아올 것이다.

내가 액세스 토큰을 받아온 방법은 조금 민망하지만 포스트맨으로
https://kauth.kakao.com/oauth/token 이 url에 get 요청을 보냈다.

get 요청을 보내기 위해서는

위 목록의 request parameter가 필요하고 grant_type은 authorization_code를, client_id에는 rest api 키, redirect_uri는 입력했던 redirect_uri(localhost~~), code는 방금 받아온 인가 코드를 넘겨줘야 한다.

이렇게 요청을 보내면

다음과 같이 결과를 받을 수 있다. 그렇다면, 이 accessToken을 가지고 Spring 내부에서 처리하는 로직을 구현해보자.

코드 구현

우선 우리는 사용자로 하여금 JWT 토큰을 기반으로 API에 접근할 수 있도록할 것이기 때문에 의존성에 아래와 같이 추가해야 한다.

build.gradle

//JWT token
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
implementation group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
implementation group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'

그리고 토큰을 암호화하기 위해서 application.yml에 아래와 같이 secret key를 설정한다.

jwt:
  secret: 뭐라고뭐라고영어+숫자해서길게적으심됩니다.

그리고 JWT를 만들어주는 JwtTokenProvider.java를 작성해주어야 한다.

JwtTokenProvider.java

@Component
@RequiredArgsConstructor
public class JwtTokenProvider {

    private static final String MEMBER_ID = "memberId";
    private static final Long TOKEN_EXPIRATION_TIME = 24 * 60 * 60 * 1000L;

    @Value("${jwt.secret}")
    private String JWT_SECRET;

    @PostConstruct
    protected void init() {
        //base64 라이브러리에서 encodeToString을 이용해서 byte[] 형식을 String 형식으로 변환
        JWT_SECRET = Base64.getEncoder().encodeToString(JWT_SECRET.getBytes(StandardCharsets.UTF_8));
    }

    public String generateToken(Authentication authentication) {
        final Date now = new Date();

        final Claims claims = Jwts.claims()
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + TOKEN_EXPIRATION_TIME));  // 만료 시간 설정

        claims.put(MEMBER_ID, authentication.getPrincipal());
        return Jwts.builder()
                .setHeaderParam(Header.TYPE, Header.JWT_TYPE) // Header
                .setClaims(claims) // Claim
                .signWith(getSigningKey()) // Signature
                .compact();
    }

    private SecretKey getSigningKey() {
        String encodedKey = Base64.getEncoder().encodeToString(JWT_SECRET.getBytes()); //SecretKey 통해 서명 생성
        return Keys.hmacShaKeyFor(encodedKey.getBytes());   //일반적으로 HMAC (Hash-based Message Authentication Code) 알고리즘 사용
    }

    public JwtValidationType validateToken(String token) {
        try {
            final Claims claims = getBody(token);
            return JwtValidationType.VALID_JWT;
        } catch (MalformedJwtException ex) {
            return JwtValidationType.INVALID_JWT_TOKEN;
        } catch (ExpiredJwtException ex) {
            return JwtValidationType.EXPIRED_JWT_TOKEN;
        } catch (UnsupportedJwtException ex) {
            return JwtValidationType.UNSUPPORTED_JWT_TOKEN;
        } catch (IllegalArgumentException ex) {
            return JwtValidationType.EMPTY_JWT;
        }
    }

    private Claims getBody(final String token) {
        return Jwts.parserBuilder()
                .setSigningKey(getSigningKey())
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    public Long getUserFromJwt(String token) {
        Claims claims = getBody(token);
        return Long.valueOf(claims.get(MEMBER_ID).toString());
    }
}

현재 validateToken에서 유효한 토큰이 아닐 경우 Enum 값을 리턴해주고 있다.
이 enum 클래스는 다음과 같이 작성한다.

JwtValidationType.java

public enum JwtValidationType {
    VALID_JWT,              // 유효한 JWT
    INVALID_JWT_SIGNATURE,      // 유효하지 않은 서명
    INVALID_JWT_TOKEN,          // 유효하지 않은 토큰
    EXPIRED_JWT_TOKEN,          // 만료된 토큰
    UNSUPPORTED_JWT_TOKEN,      // 지원하지 않는 형식의 토큰
    EMPTY_JWT                   // 빈 JWT
}

그리고 Jwt로 Security Filter에서 인증해야 하는데,

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    protected void doFilterInternal(@NonNull HttpServletRequest request,
                                    @NonNull HttpServletResponse response,
                                    @NonNull FilterChain filterChain) throws ServletException, IOException {
        try {
            final String token = getJwtFromRequest(request);
            if (jwtTokenProvider.validateToken(token) == VALID_JWT) {
                Long memberId = jwtTokenProvider.getUserFromJwt(token);
                // authentication 객체 생성 -> principal에 유저정보를 담는다.
                UserAuthentication authentication = new UserAuthentication(memberId.toString(), null, null);
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        } catch (Exception exception) {
            try {
                throw new Exception();
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }
        // 다음 필터로 요청 전달
        filterChain.doFilter(request, response);
    }

    private String getJwtFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring("Bearer ".length());
        }
        return null;
    }
}

이렇게 Authorization 헤더를 통해 Authentication 객체에 담는 필터를 생성하였다.

흐름

  1. HttpServletRequest 에서 Token을 획득
  2. JwtTokenProvider 을 통해 토큰 검증
  3. 검증에 성공한 경우, JWT로 부터 추출한 memberId으로 UserAuthentication 객체 생성
  4. 생성한 Authentication을 SecurityContextHolder에 저장
  5. 나머지 FilterChain들을 수행할 수 있도록 doFilter(request,response)를 호출

그 후에는 이 필터를 SecurityFilter에 등록해주어야 한다. 그 전에 UserAuthentication 객체가 없어서 코드에 빨간 줄이 뜰테니, 얼른 UserAuthentication을 만들어보자.

UserAuthentication.java

public class UserAuthentication extends UsernamePasswordAuthenticationToken {

    // 사용자 인증 객체 생성
    public UserAuthentication(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
        super(principal, credentials, authorities);
    }
}

상속받은UsernamePasswordAuthenticationToken 은 사용자의 인증 정보를 저장하고 전달하는 객체이다.

principal은 사용자 주체, credentials은 비밀번호와 같은 자격 증명 정보, authorities는 사용자가 가지고 있는 권한 정보이다.

SecurityConfig.java

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity //web Security를 사용할 수 있게
public class SecurityConfig {
    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final CustomJwtAuthenticationEntryPoint customJwtAuthenticationEntryPoint;
    private final CustomAccessDeniedHandler customAccessDeniedHandler;


    private static final String[] AUTH_WHITELIST = {
            "/user/kakao/signup"
    };


    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.csrf(AbstractHttpConfigurer::disable) //csrf 공격을 대비하기 위한 csrf 토큰 disable 하기
                .formLogin(AbstractHttpConfigurer::disable) //form login 비활성화 jwt를 사용하고 있으므로 폼 기반 로그인은 필요하지 않다.
                .httpBasic(AbstractHttpConfigurer::disable)//http 기본 인증은 사용자 이름과 비밀번호를 평문으로 전송하기 때문에 보안적으로 취약, 기본 인증을 비활성화 하고 있음
                .sessionManagement(session -> {
                    session.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
                })
                .exceptionHandling(exception ->
                {
                    exception.authenticationEntryPoint(customJwtAuthenticationEntryPoint);
                    exception.accessDeniedHandler(customAccessDeniedHandler);
                });

        /*
        위에는 http의 특정 보안 구성을 비활성화하고, 인증 인가에 대한 예외를 처리하고 있다.
         */
        http.authorizeHttpRequests(auth -> {
                    auth.requestMatchers(AUTH_WHITELIST).permitAll();
                    auth.anyRequest().authenticated();
                })
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
        /*
        UsernamePasswordAuthentication 클래스 앞에 jwtAuthenticationFilter를 등록한다.
         */
        return http.build();
    }
}

첫 번째 체이닝을 살펴보면, 만약에 사용자가 인증에 실패했을 경우 CustomJwtAuthenticationEntryPoint 로 처리한다.

exception.authenticationEntryPoint(customJwtAuthenticationEntryPoint);

CustomJwtAuthenticationEntryPoint.java

@Component
public class CustomJwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

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

    private void setResponse(HttpServletResponse response) {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
    }
}

그리고 사용자가 인가에 실패했을 경우 CustomAccessDeniedHandler 로 처리한다.

exception.accessDeniedHandler(customAccessDeniedHandler);

CustomAccessDeniedHandler.java

@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        setResponse(response);
    }

    private void setResponse(HttpServletResponse response) {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
    }
}

여기까지 Authorization 헤더에 Jwt 토큰을 담아 보내면 API를 사용할 수 있다. 그리고 Controller 메서드 인자로 Principle을 담아 유저를 식별할 수 있다.


여기까지 JWT 구현이었다면, 이젠 OpenFeign을 활용해 카카오에 토큰을 주고 사용자 정보를 받아오는 로직을 작성해보자.

카카오야 유저 정보 좀 줄래..

우선 사용자 정보를 외부에서 받아오기 위해서는 외부 API를 호출해야 한다.
그래서 나는 Openfeign을 활용했다. -> 3.1.2 부터는 spring-cloud 버전을 2022.0.4 버전을 사용해야 한다.

내가 사용한 의존성 및 버전은 다음과 같다.,

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.1.2'
    id 'io.spring.dependency-management' version '1.1.4'
}
-- 생략
dependencies {
-- 생략

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

ext {
    set('springCloudVersion', "2022.0.4")
}

코드

우선 사용자 정보를 받아올 객체들을 만들어 주었다.

KakaoUserResponse.java

@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record KakaoUserResponse(
        Long id,
        KakaoAccount kakaoAccount
) {
}

카카오에 엑세스 토큰을 요청을 보내면 아래와 같은 응답을 돌려준다.

우리는 여기서 id를 사용해 소셜 로그인을 한 사용자가 이미 가입을 한 사용자인지 확인할 예정이다.

KakaoAccount.java

@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record KakaoAccount(
        KakaoUserProfile profile
) {
}

KakaoUserProfile.java

@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record KakaoUserProfile(
        String nickname,
        String profileImageUrl,
        String accountEmail
) {
}

OpenFeign을 이용해 사용자의 정보를 받아오는 인터페이스를 작성해보자.

KakaoApiClient

@FeignClient(name = "kakaoApiClient", url = "https://kapi.kakao.com")
public interface KakaoApiClient {

    @GetMapping(value = "/v2/user/me")
    KakaoUserResponse getUserInformation(@RequestHeader(HttpHeaders.AUTHORIZATION) String accessToken);
}

FeignClient 빈이 생성되려면 Application 클래스의 위에 다음 어노테이션을 추가해주어야 한다.

@EnableFeignClients
@ImportAutoConfiguration(FeignAutoConfiguration.class)

이렇게 외부 API를 요청하는 인터페이스까지 작성한 후, 로직을 작성하면 된다.

KakaoSocialService.java

@Service
@RequiredArgsConstructor
public class KakaoSocialService {

    private final UserRepository userRepository;
    private final KakaoApiClient kakaoApiClient;
    private final JwtTokenProvider jwtTokenProvider;
	private final UserService userService;
    
    @Transactional
    public SignUpSuccessResponse signUp(final String accessToken) {
        // Access Token으로 유저 정보 불러오기
        KakaoUserResponse userResponse = kakaoApiClient.getUserInformation("Bearer " + accessToken);

        Long id = userSerivce.createUser(userResponse);

        UserAuthentication userAuthentication = new UserAuthentication(id, null, null);

        return SignUpSuccessResponse.of(jwtTokenProvider.generateToken(userAuthentication));
    }

UserService.java

    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();
    }

그 후 컨트롤러에서 다음과 같이 제 3 인증기관(카카오)의 액세스 토큰을 받아오는 코드를 작성하면 서버 자체 액세스토큰을 얻을 수 있다.

UserController.java

@PostMapping("/kakao/signup")
public SuccessResponse<SignUpSuccessResponse> signUp(
            @RequestParam final String accessToken
    ) {
        return SuccessResponse.of(SuccessMessage.SIGNUP_SUCCESS, kakaoSocialService.signUp(accessToken));
}

다음과 같이 api에 대한 응답이 오는 것을 확인할 수 있다.

이렇게 유저 테이블에 내 정보가 담겨있는 것도 확인 가능하다.

마무리

소셜 로그인, 정말 멀게만 느껴졌었는데 끝까지 구현하고 나니 인가 코드를 받아서 처리하는 것도 어렵지 않겠다는 생각이 든다.
다음 번에는 redis와 연동하여 refresh token도 함께 반환하여 자동 로그인을 구현해보고자 한다!

profile
하루에 한 걸음씩

1개의 댓글

comment-user-thumbnail
2024년 6월 1일

안녕하세요! 글 잘 읽고 갑니다! 도움이 많이 되어 이렇게 댓글 남깁니다. 감사합니다!

답글 달기