본문 바로가기
Spring Security

Spring Security + OAuth2 + JWT 구현 일지

by 딱구킴 2022. 10. 5.

말씀드리기 앞서, 저는 웹 개발을 본격적으로 시작한지 6개월이 채 안된 개린이라는 점 양해 부탁드립니다.
이에 따라, 틀린 부분이 있을 경우 지적을 해주신다면 정말 감사드리겠습니다.

이번에 사이드 프로젝트에서 Spring Security + OAuth2 + JWT를 이용하여 인증 및 인가 처리를 진행하기로 했습니다.

 

서버단에서 서버사이드 렌더링으로 Spring Security + OAuth2를 구현할 때는 프레임워크 덕분에 손쉽게 진행했던 기억이 있어서, 자신감있게 제가 설계 해보겠다고 말씀드렸고, 설계를 수행하게 됐습니다.

 

그런데, 제가 간과한점들이 몇개 있었습니다.

  • 프론트 서버에서 로그인 버튼을 눌렀을 때, 어떤 방식으로 OAuth2 인증이 수행될까?
  • 나는 어떤 정보를 받는것이고, 내가 그 정보를 받아서 어떻게 처리해야되지..? 지금까지는 Spring Security가 다 해줬는데...
  • 그리고 JWT는 어떻게 발급해주는거지?

 

지금까지 Spring Security 프레임워크에 전적으로 의존하여 모든걸 구현해봤던 저는 대체 어떻게 설계해야 할지 감을 잡을 수가 없었습니다. 그래서, 이곳 저곳 모든 블로그들과 GitHub 리포지토리들을 본격적으로 뒤져보기 시작했고, 보통 다음과 같은 아키텍처로 설계함을 알 수 있었습니다.

 

그림 출처 - [OAuth + Spring Boot + JWT] 1. OAuth란? 프론트엔드와 백엔드의 역할

이렇게 보면 정말 간단해 보입니다. 하지만 저걸 어떻게 구현하지? 라는 생각이 머리를 스쳤습니다.

 

저는 Javascript & JQuery가 아닌, 백엔드 서버에서 자체적으로 API 요청을 보내본 적이 없기 때문입니다. 게다가, 서버사이드 렌더링을 통해 게시판을 구현했을 때는 Spring Security가 모든 과정을 전부 다 해주었기 때문에, 저렇게 역할을 나누어서 할 때 Spring Security가 어떤 역할을 하도록 구성해야 하는지도 몰랐습니다.

 

이걸 구현 해보기 위해서, 무작정 GitHub 레포지토리에 구현된 코드들을 따라해보기도 하고, 블로그에 떠돌아다니는 코드들을 전부 따라해보기도 했습니다. 뭐, 이에 대한 결과는 아주 처참했습니다. 남는 것 없이 에러만 가득한 화면을 볼 수 있었습니다.

이렇게는 안되겠다는 생각이 들었습니다.

 

지금이 Spring Security를 공부할 때구나 라는 생각이 들었습니다. 그래서, 팀원분들께 "제가 설계하겠다고 말씀 드렸는데 죄송합니다. 제가 생각보다 모르는 부분이 많더라고요. 구현까지 조금 시간이 걸릴 것 같아요.. 최대한 빠른 시일 내에 해결해보겠습니다."라고 양해를 구한 뒤, 공부에 전념했습니다.

 

먼저, JWT가 무엇인지 아는 것이 중요했습니다.

그래서 JWT 관련 개념들을 공부하기 시작했고, JWT를 이용한 인증 방식을 간단히 구현해둔 코드들을 따라해보고, 관련 필터에 디버그 포인트를 걸고 전부 분석해보기 시작했습니다. 이로 인해, JWT가 어떻게 발급되는지, Spring Security의 Filter가 어떻게 동작하는지를 공부할 수 있었습니다. (참고 사이트: https://github.com/murraco/spring-boot-jwt)

 

이제, JWT와 OAuth2를 어떻게 접목시키는지를 알아야 했습니다. 그래서 이것도 예제들을 따라하고, 디버그 포인트를 걸고 분석해보며 어떤 방식으로 구동되는지 알 수 있었습니다 (참고사이트 : [Spring] Security +Google Oauth2 + JWT 구현하기 (1) - JWT 생성하기)

 

자, 이제 좀 알았으니, 나도 구현을 할 수 있지 않을까? 라는 생각에 구현을 시작해보았지만, 초장부터 막혔습니다.

프론트엔드가 받은 Authorization code를 전송해주면, 대체 이걸 어떻게 Authorization Server에 보내고, AccessToken을 받고, 심지어 Resource server에 정보 요청까지 할 수 있는건지 그 방법을 몰랐습니다.

 

그래서 해당 방법들을 찾아 헤메기 시작했습니다. 그러다가 발견한 것이 RestTemplate라는 라이브러리였고, 이를 이용하면 내가 받은 Authorization code를 Uri에 담아 Authorization server에 요청을 보내고, 그 응답을 받아올 수 있었습니다.

 

다만, 해당 라이브러리는 조만간 Deprecated 대상이었고, 실제로 Spring5부터 지원되지 않는다는 내용을 보았고, 그의 대안책으로써 WebClient를 사용하라는 권고가 있다는 것을 알게되었습니다. 그래서 WebClient를 사용해서 Authorization Server에 Authorization code를 보내(부가적으로 다른 정보도 보내야 합니다.) AccessToken을 발급받고, 그 토큰을 헤더에 심어 보내 유저 정보를 얻어오는 것 까지 성공했습니다.

 

@PostMapping("/test")
public TokenDto test1(@RequestBody RequestDto naverDto) {
    // 여기서도 받아온 값을 네이버에 POST로 보내고, 정보를 받아오는 것 까지 해보자.
    log.info("code = {}", naverDto.getCode());
    log.info("state = {}", naverDto.getState());

    WebClient webClient = WebClient.create();

    UriComponents uri = UriComponentsBuilder.newInstance()
            .scheme("https")
            .host("nid.naver.com")
            .path("/oauth2.0/token")
            .queryParam("grant_type", "authorization_code")
            .queryParam("client_id", clientIdNaver)
            .queryParam("client_secret", clientSecretNaver)
            .queryParam("code", naverDto.getCode())
            .queryParam("state", naverDto.getState())
            .build();

    // 받아온 값을 네이버에 쏴준다. url 형식을 지켜서!
    // 엑... 할당이 안되네 ㅠ 왜지 ㅠ -> POST로 날리고 POST로 받아보자. -> 어림도없지
    // 이름을 JSON 데이터 형식과 똑같이 바꿔주니까 바인딩이 잘 된다!! (카멜 타입 적용 안됨..ㅠ)
    TokenDto tokenDtoMono = webClient.get()
            .uri(uri.toString()).accept(MediaType.APPLICATION_JSON)
            .retrieve()
            .bodyToMono(TokenDto.class)
            .block();

    log.info("what is tokenDtoMono? = {}", tokenDtoMono.getAccess_token());  // 오 값이 받아진다!

    // 그렇다면 받은 값의 access 토큰만 얻어내서 다시 naver에 쏜다.
    String userData = webClient.get()
            .uri("https://openapi.naver.com/v1/nid/me")
            .headers(h -> h.setBearerAuth(tokenDtoMono.getAccess_token()))
            .retrieve()
            .bodyToMono(String.class)
            .block();

    log.info("what is userData? = {}", userData);

    // 주고 받는거 전부 성공!! 근데, 음.. 인증으로 어떻게 연결을 하지?
    // 이것은 Security랑 WebClient 공부가 더 필요한듯.
    // 이렇게도 어거지로는 구성이 될 것 같은데.. "책임"의 분리가 안된다.. 그렇게 되면 유지보수 빡세진다..
    // 꼭 스프링 시큐리티를 적용 해보도록 하자!

    return tokenDtoMono;
}

 

이제 이걸 이용해서, Custom Filter를 구성해서 Filter에서 요청을 받고 위 로직을 처리하면 되겠구나! 라고 결론을 내렸지만, 알 수 없는 찝찝함과 함께 두 가지 의문이 들었습니다.

 

  • 위와 같이 구성할 경우, Spring Security + OAuth2 프레임워크가 해주는 역할은 프론트 서버가 로그인 버튼을 눌렀을 때 Authorization 서버로 요청을 보내고, Authorization code를 프론트엔드로 리디렉션 하는 역할밖에 해주지 않는다. 뭔가 SpringSecurity나 OAuth2 프레임워크를 제대로 사용하지 않는 것 같다.
  • 내가 직접 구성한 필터가 보안에 취약할 수도 있을 것 같다.

 

서버사이드 렌더링으로 구현할 때, Authorization code를 받아오는 행위나, 유저 정보를 불러오는 등의 행위는 Spring Security + OAuth2 프레임워크가 전부 수행해주었습니다.

 

제가 필터에서 요청을 받아 WebClient로 여러 정보를 받아오는 것 보다는, Spring Security + OAuth2 프레임워크를 이용하는 것이 좀 더 안전할 것이라는 판단이 들었습니다. 또한, 서버사이드 렌더링에서는 자동으로 처리해주었던 편리함을 사용하지 못하게 된다고 생각하니, 약간 비효율 적이라는 생각도 들었습니다.

 

저는 왠지 저렇게 WebClient로 직접 구성하는 것 보다는, Spring Security + OAuth2 라는 잘 만들어진 바퀴를 제대로 이용하고 싶었습니다. 그래서, 이를 제대로 이용하기 위한 방법을 좀 더 제대로 고민해보기 위해 "스프링 시큐리티 인 액션"이라는 책을 구매해서 정독을 했고, "스프링 시큐리티 공식문서"도 참고하여 더 자세히 알아보았습니다.

 

Spring Security 아키텍처의 대략적인 큰 그림은 아래와 같습니다.

그림은 UsernamePassword 인증 관련 아키텍처이지만, 구동 방식에 있어서는 사용되는 객체만 다를 뿐, 크게 다른 점이 없습니다. 단지, 해당 흐름을 파악하고 글을 읽으신다면, 좀 더 이해에 도움이 될 것 같아서 넣었습니다. 결과적으로, Spring Security는 인증이 완료된 객체를 SecurityContext에 저장하는게 목표입니다. (이를 구현해야 합니다.)

그리고, OAuth2 인증에 사용되는 필터는 다음과 같습니다. OAuth2 로그인 시, 크게 두 개의 필터가 작동합니다.

  • OAuth2AuthorizationRequestRedirectFilter
    • Authorization code를 받아 리디렉션 URI로 리디렉션 해주는 필터
  • OAuth2LoginAuthenticationFilter
    • 받은 Authorization code를 가지고 AccessToken 요청 및 유저 정보 요청을 해주는 필터 (전부 다 해당 필터에서 함)

 

// OAuth2AuthorizationRequestRedirectFilter
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
        throws ServletException, IOException {
        
    	// redirection 관련 로직을 수행합니다.
    }
}

// OAuth2LoginAuthenticationFilter
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
      throws AuthenticationException {
      
   MultiValueMap<String, String> params = OAuth2AuthorizationResponseUtils.toMultiMap(request.getParameterMap());
   
   // authorization code를 받아, Authorization server에 AccessToken발급을 요청합니다.
   // 그 이후, AccessToken을 이용하여 유저의 정보를 받아옵니다.
   // 유저의 정보를 UserDetails에 해당하는 OAuth2User 객체로 변환합니다.
   // OAuth2User를 OAuth2LoginAuthenticationToken으로 변환합니다.(인증 객체)
   // 마지막으로 SecurityContextHodler에 인증 객체를 저장합니다.

   this.authorizedClientRepository.saveAuthorizedClient(authorizedClient, oauth2Authentication, request, response);
   return oauth2Authentication;
}

저는 OAuth2AuthorizationRequestRedirectFilter를 이용해서 프론트에 리디렉션을 시켜준 뒤, 프론트가 Authorization code를 받으면 OAuth2LoginAuthenticationFilter를 Trigger 시키면 되겠다 라고 생각했습니다.

 

프론트가 code를 전송해주는 Uri만 특정 Filter가 작동되게 하고, 그 필터가 OAuth2LoginAuthenticationFilter 이전에 오도록 설계하면 되겠구나. 라고 생각했지만, 쉽지만은 않았는데, 신경써야 할 요소가 많았기 때문입니다.

 

제가 위와 같이 필터를 구성한다면, OAuth2AuthorizationRequestRedirectFilter에서 받아온 값을 -> OAuth2LoginAuthenticationFilter의 request.parameterMap에 넘겨줘야 할 것입니다.

 

그래서, OAuth2AuthorizationRequestRedirectFilter는 LoginFilter에 무슨 값을 보내는지 알 필요가 있었습니다. 아래와 같이 Test filter를 구성하여 무슨 값이 넘어가는지 확인 해보았습니다.

 

@Slf4j
public class TestFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        Map<String, String[]> parameterMap = request.getParameterMap();

        for (String key : parameterMap.keySet()) {
            log.info("parameter = {}", key);

            String[] values = parameterMap.get(key);
            for (String value : values) {
                log.info("value = {}", value);
            }
        }

        log.info("안녕 이게 작동은 되니?");

        filterChain.doFilter(request, response);

    }

}

 

state, code, scope, authuser, prompt라는 키에 값들이 할당되어 넘어옵니다. 실제로, URI는 아래와 같이 구성되어있습니다. 해당 URI의 파라미터를 파싱한 값이 request.parameterMap에 담깁니다.

 

"http://localhost:8080/login/oauth2/code/google?state=lUIW8A2igGpxh9EkU8jEsOFrYW7inw4BZ88nmaBsQ-U=&code=4/0ARtbsJpmNa30ePWhm2Iey_vzT9fAFWeq17-HWaQDb3PXbY_N0YROtouA8QpyzAMFu-EoQQ&scope=email+profile+https://www.googleapis.com/auth/userinfo.email+openid+https://www.googleapis.com/auth/userinfo.profile&authuser=0&prompt=none"

 

이 값들을 프론트엔드 서버가 "요청 파라미터에 담아" 백엔드 서버의 특정 엔트포인트로 보내주면, 위의 TestFilter가 trigger 되도록 하고, 값을 바로 OAuth2LoginAuthenticationFilter에 넘겨주기만 하면 될 것 같습니다.

 

특정 엔드포인트에만 필터가 작동되도록 하는 방법은 다음과 같습니다. 엔드포인트는 지금은 쉽게 @RequestMapping("/엔드포인트주소")라고 생각하면 되겠습니다. 아래와 같이 작성할 경우, /test 엔드포인트를 제외한 다른 요청에 대해서는 해당 필터가 작동하지 않습니다.

@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
    return !request.getRequestURI().equals("/test");
}

이렇게 구현할 경우, 프론트엔드가 위의 URI 형식만 맞춰준다면, 손쉽게 구현이 될 것 같습니다.

 

하지만, 사실 이 방법도 번거롭다고 생각합니다. 어차피 OAuth2AuthorizationRequestRedirectFilter와 OAuth2LoginAuthenticationFilter를 전부 사용할건데, 그냥 OAuth2 관련 인증 및 인가 로직은 백엔드 서버에서 전부 구현하는게 더 효율적이라고 생각했습니다.

 

그래서 아래와 같은 아키텍처를 구상해보았습니다.

  • OAuth2 인증을 백엔드 단에서 전부 하도록 구성합니다.
    • 즉 프론트엔드는 OAuth2 인증 및 인가 과정에는 참여하지 않습니다. 그저 백엔드 서버로 연결되는 로그인 버튼만 구현합니다.
  • 그리고 나서, OAuth2 로그인이 성공하면 작동될 OAuth2SuccessHandler를 구현합니다. 이는 DB에서 회원 정보를 조회하여 회원 가입 여부를 판별합니다.
    • 회원이 아닌 유저는 요청 파라미터에 loginSuccess=false 값을 담아 프론트엔드 서버로 리디렉션됩니다.
    • 회원인 유저는 JWT AccessToken을 발급받고, 요청 파라미터에 loginSuccess=true와 token 값을 담아 프론트 서버로 리디렉션 되며, 로그인 처리가 완료됩니다.

 

이 설계를 아래와 같이 구현하였습니다.

@Component
@Transactional
@RequiredArgsConstructor
public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler {

    private final MemberRepository memberRepository;
    private final JwtProvider jwtProvider;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) throws IOException, ServletException {

        OAuth2User oauth2User = (OAuth2User) authentication.getPrincipal();
        Map<String, Object> attributes = oauth2User.getAttributes();
        String authId = (String) attributes.get("id");

        Optional<Member> findMember = memberRepository.findByAuthId(authId);

        UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(UriList.FRONT_END.getUri());

        if (findMember.isEmpty()) {

            String redirectionUri = uriBuilder
                            .queryParam("loginSuccess", false)
                            .build()
                            .toUriString();

            response.sendRedirect(redirectionUri);
        } else {

            Member member = findMember.get();

            String accessToken = jwtProvider.generateAccessToken(oauth2User);
            String refreshToken = jwtProvider.generateRefreshToken();

            // 액세스 토큰에는 어차피 authId 정보가 있기 때문에, access 토큰을 멤버에 저장할 필요가 없다.
            member.saveRefreshToken(refreshToken);

            String redirectionUri = uriBuilder
                    .queryParam("loginSuccess", true)
                    .queryParam("token", accessToken)
                    .build()
                    .toUriString();

            response.sendRedirect(redirectionUri);
        }
    }
}

코드 안에서는 RefreshToken도 발급하여 member 엔티티에 저장합니다. 하지만, AccessToken을 갱신하기 위해, 프론트엔드 서버와 RefreshToken을 주고 받으며 해당 정보가 노출되는 것도 보안 상 위험이 존재할 수 있다고 판단하였습니다.

 

그래서, 팀끼리 논의한 끝에, AccessToken만 발급하고, 만료 시 재 로그인을 유도하기로 결정하였습니다. (즉, RefreshToken은 사용하지 않습니다.)

 

토큰을 발급해주는 로직은 다음과 같습니다.

  • @Value 를 이용해서, application-jwt.yml에 저장된 값을 불러와서 사용하였습니다.
  • 이는 여러 GitHub와 강의들, 블로그를 참고하여 재구성한 코드입니다. 해당 부분은 자료가 많으니 설명을 생략하도록 하겠습니다.

 

import org.springframework.beans.factory.annotation.Value; // @Value

@Slf4j
@Component
public class JwtProvider {

    private final static String AUTHORITIES_KEY = "authority";
    private final static String BEARER_TYPE = "Bearer";

    @Value("${jwt.access-token-expire-time}")
    private long ACCESS_TOKEN_EXPIRE_TIME;

    @Value("${jwt.refresh-token-expire-time}")
    private long REFRESH_TOKEN_EXPIRE_TIME;

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

    private Key key;

    // 빈 생성 및 생성자 주입이 다 끝난 후에 Key를 생성
    @PostConstruct
    private void init() {
        byte[] keyBytes = Decoders.BASE64.decode(secret);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    // 액세스 토큰 발급
    public String generateAccessToken(OAuth2User oAuth2User) {
        long now = new Date().getTime();
        Date accessTokenExpireTime = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);

        Map<String, Object> attributes = oAuth2User.getAttributes();

        // Authorities를 Claim에 넣을 수 있도록 String으로 변경 (authority1,authority2,..)
        String authorities = oAuth2User.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        return Jwts.builder()
                .setSubject((String) attributes.get("id"))
                .claim(AUTHORITIES_KEY, authorities)
                .setExpiration(accessTokenExpireTime)
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();
    }

    // 리프레시 토큰 발급
    public String generateRefreshToken() {
        long now = new Date().getTime();
        Date refreshTokenExpireTime = new Date(now + REFRESH_TOKEN_EXPIRE_TIME);

        return Jwts.builder()
                .setExpiration(refreshTokenExpireTime)
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();
    }


    // 요청 헤더에서 토큰 꺼내오기
    public String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(HttpHeaders.AUTHORIZATION);

        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(AccessTokenType.BEARER.getValue())) {
            return bearerToken.substring(7);
        }

        return "";
    }

    // 토큰 검증하기
    public Boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (ExpiredJwtException e) {  // 토큰이 만료되었다면
            throw new JwtNotAvailable("토큰이 만료되었습니다. 다시 로그인 해주세요.");
        } catch (SecurityException | MalformedJwtException | IllegalArgumentException | UnsupportedJwtException e) {
            throw new JwtNotAvailable("올바르지 않은 토큰입니다.");
        }
    }

    // 액세스 토큰으로 Authentication 만들기
    public Authentication authenticate(String accessToken) {
        Claims claims = parseClaims(accessToken);

        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());

        User user = new User(claims.getSubject(), "", authorities);

        return new UsernamePasswordAuthenticationToken(user, "", authorities);
    }

    // Claim을 파싱하는 메서드
    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }

그 다음은, 회원이 아닌 사람의 회원 가입 로직을 구상하였습니다.

회원이 아닌 유저는 회원 가입을 해야합니다. 회원 가입을 요청할 때는 닉네임만 작성하여 넘겨주면 됩니다. (닉네임 중복 여부는 따로 체크합니다.)

 

회원 가입 로직은, 별다른 커스텀 필터를 통과하지 않도록 구상하였습니다. 아래서 설명드리겠지만, JWT 유효성을 평가하는 JwtFilter를 shouldNotFilter 메서드를 이용해서 회원 가입 로직만 제외하였습니다.

 

회원이 회원 가입을 요청하고, 엔드포인트에 도달하면, member service단에서 가입을 처리합니다.

이 때 엔드포인트에서는 유저가 기입한 닉네임 정보와, OAuth2User 정보를 파라미터로 받습니다.

// 회원 가입 엔드포인트
@PostMapping("/signup")
public SignUpMemberResponse signup(@RequestBody SignUpDto signUpDto,
                                   @AuthenticationPrincipal OAuth2User oAuth2User) {

    return memberService.signUp(signUpDto, oAuth2User);
}
  • 회원 가입을 요청하는 유저는 OAuth2 인증을 받은 상태입니다. 그렇기 때문에, 엔드포인트에서 해당 정보를 받아 사용할 수 있습니다.
  • 저는 member 엔티티에 유저의 authId(OAuth2 인증을 받으면 기본적으로 Id 값이 부여됩니다.), name, email 등을 저장하도록 설계하였기 때문에, 해당 정보가 필요합니다. 유저의 구분은 AuthId로 수행할 계획입니다.
  • 해당 정보들을 서비스단으로 넘겨 처리합니다.

 

이 때 서비스단이 하는 역할은 두가지입니다.

  • 리포지토리에 회원을 저장한다.
  • AccessToken을 발급한다.

 

해당 역할을 수행하기 위해, 아래와 같이 코드를 구성하였습니다.

 

public SignUpMemberResponse signUp(SignUpDto signUpDto, OAuth2User oAuth2User) {

    Map<String, Object> attributes = oAuth2User.getAttributes();

    String accessToken = jwtProvider.generateAccessToken(oAuth2User);
    String refreshToken = jwtProvider.generateRefreshToken();

    String authId = (String) attributes.get("id");

    // TODO: 어떻게 변경할지 나중에 다시 생각. authId 중복 가입은 이미 OAuth2 filter에서 막아주고 있음.
    if (memberRepository.existsByAuthId(authId)) {
        throw new IllegalArgumentException("이미 존재하는 회원입니다.");
    }

    Member member = Member.builder()
            .name(signUpDto.getName())
            .authId(authId)
            .email((String) attributes.get("sub"))
            .picture((String) attributes.get("picture"))
            .roleType(RoleType.USER)
            .refreshToken(refreshToken)
            .build();

    memberRepository.save(member);

    return new SignUpMemberResponse(member, accessToken);
}

 

마지막으로, 프론트엔드에서 AccessToken으로 백엔드 서버에 요청하도록 하는 로직을 다음과 같이 설계 및 구현하였습니다.

이 때, JwtFilter가 작동하게 하여 토큰의 유효성을 평가합니다.

  • 토큰이 유효할 경우, 인증이 완료되어 UsernamePasswordAuthenticationToken이라는 Authentication 객체를 생성하고 이를 SecurityContext에 등록합니다.
  • 토큰이 유효하지 않을 경우, 에러 응답을 프론트 서버에 보내줍니다. 해당 응답을 받은 프론트 서버는 유저의 "재 로그인"을 유도합니다.

 

이를 구현하기 위해, 토큰이 유효하지 않을 경우, 예외를 터트리고 그 예외를 프론트 서버에 보내는 방법을 더 고민해봐야 했습니다.

고민 끝에, 아래와 같이 구현을 하였습니다.

 

// JwtFilter
@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

    private final JwtProvider jwtProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        String jwt = jwtProvider.resolveToken(request);

        if (StringUtils.hasText(jwt)) {  // 토큰이 있으면
            if (jwtProvider.validateToken(jwt)) {  // 토큰을 검증하고 (올바르지 않은 경우 여기서 예외 발생)
                // 토큰이 유효하면
                Authentication authentication = jwtProvider.authenticate(jwt);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        } else {
            log.info("미인증 사용자 입니다.");
        }

        // 토큰이 없으면 SecurityContext에 인증 객체 넣지 않고 그냥 필터 진행
        filterChain.doFilter(request, response);
    }

    // 회원가입 시에는 해당 필터 미적용
    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
        return request.getRequestURI().equals("/api/signup");
    }
}
// JwtExceptionFilter
@RequiredArgsConstructor
public class JwtExceptionFilter extends OncePerRequestFilter {

    private final ObjectMapper objectMapper;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        try {
            filterChain.doFilter(request, response);
        } catch (JwtNotAvailable e) {
            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
            response.setCharacterEncoding("UTF-8");

            ErrorResult errorResult = ErrorResult.builder()
                    .code(e.getErrorCode())
                    .status(e.getStatus())
                    .message(e.getMessage())
                    .build();

            objectMapper.writeValue(response.getWriter(), errorResult);
        }
    }
}

ExceptionFilter 구성은 해당 블로그를 참고하였습니다 -> 참고 블로그

  • JwtExceptionFilter를 JwtFilter 전에 오도록 구성해줍니다.
  • JwtFilter에서 예외가 터질 경우, JwtExceptionFilter로 내려옵니다. 이를 처리하여 프론트 서버에 JSON 형태로 에러 응답을 내려줍니다.

 

Filter의 위치 설정은 addFilterBefore를 이용하여 다음과 같이 구성합니다.

@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final CustomUserDetailService customUserDetailService;
    private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler;
    private final JwtProvider jwtProvider;
    private final ObjectMapper objectMapper;
	
    // h2-console을 사용하기 위해 인증 무시 url을 구성하였습니다.
    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return web -> web.ignoring().mvcMatchers(
                "/h2-console/**",
                "/favicon.ico",
                "/error"
        );
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.NEVER)

                .and()
                .authorizeRequests(expressionInterceptUrlRegistry -> expressionInterceptUrlRegistry
                        .mvcMatchers("/api/posts").permitAll()
                        .mvcMatchers("/api/search").permitAll()
                        .mvcMatchers(HttpMethod.GET, "/api/member").permitAll()
                        .mvcMatchers(HttpMethod.GET, "/api/post/**").permitAll()
                        .anyRequest().authenticated())

                .oauth2Login(httpSecurityOAuth2LoginConfigurer -> httpSecurityOAuth2LoginConfigurer
                        .loginPage(UriList.FRONT_END.getUri())
                        .userInfoEndpoint()
                        .userService(customUserDetailService)
                        .and()
                        .successHandler(oAuth2LoginSuccessHandler))

                .addFilterBefore(new JwtFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(new JwtExceptionFilter(objectMapper), JwtFilter.class);

        return http.build();
    }
}

 

약 일주일 반~이주일 정도 들여서 로직 설계를 완료했습니다. 

이 외에도 PostMan으로 테스트하여 로그인 로직이 성공적으로 작동함 + JwtAccessToken이 정상적으로 발급되고 만료됨을 확인하였습니다. 

 

이제와서 돌이켜보면, 너무 간단하게 할 수 있던 설계였던 것 같습니다.

이렇게 간단했음에도, 설계에 쉽사리 접근하지 못했던 이유는 "기본기의 부족"이 원인이었던  것 같습니다.

 

앞으로 새로운 것을 도입할 때는, 사용 이유를 찾고 "기본기"를 반드시 학습한 후 사용해야겠다고 다짐했습니다.

물론, 저의 시간은 한정되어 있기 때문에, 너무 기본기에만 매몰될 수는 없을 것 같습니다. 

 

모든 코드는 https://github.com/SeolYoungKim/siders 에 있습니다.

 

보실 분이 계실지 모르겠지만 🥲

해당 코드는 토이 프로젝트 전체 코드임을 감안하고 봐주시면 감사드리겠습니다.

 

 


리팩토링 & Issue

OAuth2SuccessHandler를 리팩토링 하였습니다.

@Component
@Transactional
@RequiredArgsConstructor
public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler {

    private final MemberRepository memberRepository;
    private final JwtProvider jwtProvider;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) throws IOException, ServletException {
        OAuth2User oauth2User = (OAuth2User) authentication.getPrincipal();
        Optional<Member> findMember = memberRepository.findByAuthId(getAuthId(oauth2User));

        addJwtToCookie(oauth2User, response);  // 쿠키에 JWT를 담아줍니다.

        UriComponentsBuilder uriBuilder = UriComponentsBuilder
                .fromUriString(UriList.FRONT_END.getUri());

        if (findMember.isEmpty()) {
            String redirectionUri = uriBuilder
                    .queryParam("loginSuccess", false)
                    .build()
                    .toUriString();

            response.sendRedirect(redirectionUri);
            return;
        }

        Member member = findMember.get();
        member.saveRefreshToken(jwtProvider.generateRefreshToken());

        String redirectionUri = uriBuilder
                .queryParam("loginSuccess", true)
                .build()
                .toUriString();

        response.sendRedirect(redirectionUri);

    }
	
    // 쿠키에 JWT를 담아줍니다.
    private void addJwtToCookie(OAuth2User oauth2User, HttpServletResponse response) {
        final String COOKIE_NAME = "token";
        String accessToken = jwtProvider.generateAccessToken(oauth2User);

        Cookie cookie = new Cookie(COOKIE_NAME, accessToken);
        cookie.setMaxAge(3600);  // 테스트 후 더 짧게 유지할 계획입니다.
        cookie.setPath("/");

        response.addCookie(cookie);
    }

    private String getAuthId(OAuth2User oauth2User) {
        Map<String, Object> attributes = oauth2User.getAttributes();
        return (String) attributes.get("id");
    }
}

JWT 관련

  • 기존 : uri에 JWT 토큰을 담아 리디렉션
  • 변경 : 쿠키에 JWT 토큰을 담아 리디렉션 

uri에 토큰을 담아주면 상당히 지저분해보여서 이와 같은 결정을 하였습니다. 쿠키는 만료시간을 짧게 주었습니다. 옳은 선택일지는 모르겠지만, 리디렉션으로는 헤더에 토큰을 줄 수 없어서 이와 같은 선택을 하였습니다.

 

비회원 관련 Issue

기존에 OAuth2SuccessHandler에서, OAuth2 유저 정보를 기반으로 MemberRepository에서 회원 여부를 조회하여, 회원이 아닐 경우 토큰 없이 리디렉션을 시켜주었었습니다. 그런데, 한 가지 큰 이슈로 인해 방향을 바꾸었습니다.

 

당초에는 비회원을 리액트 서버로 리디렉션을 해주면, OAuth2User 인증 정보가 SecurityContext에 남아, 리액트 서버에서 재요청을 했을 때 세션에 남아있을 줄 알았습니다.

 

하지만, 인증 정보는 세션에 남아있지 않았고,

비회원이 OAuth2 인증 후 -> OAuth2SuccessHandler에서 리디렉션을 받고 -> 회원가입을 다시 요청했을 때에는 OAuth2User 인증 정보가 넘어오지 않아 회원가입을 할 수 없는 사태가 발생했습니다.

 

이를 어떻게 해결할지 참 난감했습니다. 세션을 유지하게 해보기도 하고, 별 수를 다 썼지만 OAuth2 인증 정보를 유지할 수가 없었습니다. 아무래도 인증 정보를 내부의 어딘가에 저장해두었다가 사용해야 할 것 같은데, 이 방법을 알아보고 있는 중이며, 해결 되는 대로 공유드리겠습니다.

 

일단은 프론트엔드 개발자님께서도 기능 테스트를 해야했기 때문에, 세션을 stateless로 해두고, 임시 방편으로써 비회원도 JWT를 발급해주고 있는 상황입니다.. 혹시 이 해결방법을 아시는 분이 계시다면 댓글로 알려주시면 감사드리겠습니다......

 

 

댓글