SpringBoot

[SpringBoot] 로그인/세션 등 리액트 연동 문제 해결(2) JWT + 스프링 + 카카오/구글/네이버 로그인, 세션, 스프링 부트와 AWS로 혼자 구현하는 웹 서비스

폭풍저그김탁구 2023. 1. 24. 00:15

아무튼 전 편에 이어서 이야기를 해보자면, 리액트의 주소와 스프링의 주소가 달라서 문제들이 생겼었다.

 

마지막 문제는 세션 문제였다. 그래서 과감히 세션을 버리고 토큰 방식으로 옮겼다.

 

 


 

* 방식

나는 스프링 시큐리티를 이용했기 때문에 프론트에서 인가 코드를 직접 이용하고 그러지는 않았다.

스프링이 로그인 과정은 다 해준다.

로그인이 되면 백엔드에서 정보 따서 토큰화 한다음 프론트로 주고,

프론트에서는 토큰 저장해놨다가 백엔드로 보내주고

백엔드는 유효한지 체크해주는 것!

 

내가 작성한 플로우는

  1. 클라이언트는 로그인을 시도한다 (/oauth2/authorization/google)
  2. 스프링에서 로그인을 한 다음 사용자 정보를 백엔드로 넘겨준다
  3. 백엔드는 사용자 정보를 좋게 JWT에 담아서 얘를 리액트에게 리다이렉트 시킨다
  4. 리액트는 스토리지에 담아뒀다가 얘를 헤더에 담아 인증을 받는다

 

아래는... 코드를 담았는데 솔직히 시간에 쫓기느라 좀 대충 짰다.

이것저것 고려하지 않은 코드이니 그냥 돌아간다~만 봐주시길.

서비스, 도메인 이런 구분 제대로 못한 거 같아서 부끄럽다.

 

 

 


 

1. JWT 관련 작성

먼저 JWT 토큰을 만들고 검사해 줄 유틸리티를 하나 만든다.

 

JwtUtil.java

import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import io.jsonwebtoken.io.Decoders;

import java.security.Key;
import java.util.Date;

@Slf4j
@Component
public class JwtUtil {

    private final Key key;
    private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 60; //access 60분
    private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 14; //refresh 14일;

//    SECRET: 256 bits (32 byte) 이상
    public JwtUtil(@Value("${jwt.secret}") String SECRET) {
        byte[] keyBytes = Decoders.BASE64.decode(SECRET);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    /**
     * 토큰 생성
     */
    public JwtTokenResponse generateToken(Long userId) {
        System.out.println("generate: ");
        System.out.println(userId);
        long now = System.currentTimeMillis();

        String accessToken = Jwts.builder()
                .claim("userId", userId)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(now + ACCESS_TOKEN_EXPIRE_TIME)) // 1시간
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();

        // claim 없이 만료 시간만 담기
        String refreshToken = Jwts.builder()
                .setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();

        return JwtTokenResponse.builder()
                .grantType("bearer")
                .accessToken(accessToken)
                .accessTokenExpiresIn(new Date(now + ACCESS_TOKEN_EXPIRE_TIME).getTime())
                .refreshToken(refreshToken)
                .build();
    }


    /**
     * 토큰 유효여부 확인
     */
    /* 토큰 유효성 검증, boolean */
    public boolean isValidToken(String token) {
        try{
            getAllClaims(token);
            return true;
        } catch (MalformedJwtException e) {
            log.info("JWT 서명의 형식이 잘못되었습니다.");
        } catch (ExpiredJwtException e) {
            log.info("만료된 JWT 입니다.");
        } catch (UnsupportedJwtException e) {
            log.info("지원하지 않는 JWT 입니다.");
        } catch (IllegalArgumentException e) {
            log.info("잘못된 JWT 입니다.");
        }
        return false;
    }

    /**
     * 토큰의 Claim 디코딩
     */
    public Claims getAllClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();
    }


}

- 토큰은 각각 1시간, 2주로 잡았다.

- 시크릿 키는 properties 파일로 빼서 환경변수처럼 썼다. 얘는 256 바이트나 되는 긴 문자열이어야 한다.

- generateToken: userId(Long)을 토큰에 담아 accessToken을, 만료시간만 담은 refreshToken을 만들어 DTO에 넣었다.

- 아래에는 토큰 유효 검사용, 클레임 꺼내는 용 함수들

- 저 parserBuilder가 안 된다면 라이브러리 버전을 높이면 된다. 저거 안 쓰면 오류 떴음.

 

 

JwtAuthenticationFilter.java

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String BEARER_PREFIX = "bearer ";
    private final JwtUtil jwtUtil;

    // JWT의 인증 정보를 현재 쓰레드의 SecurityContext에 저장
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain filterChain) throws IOException, ServletException {

        String jwt = resolveToken(request);
        jwtUtil.isValidToken(jwt);
        filterChain.doFilter(request, response);

    }

    // request header 에서 토큰 꺼내오는 메소드
    private String resolveToken(HttpServletRequest request) {
        String bearToken = request.getHeader(AUTHORIZATION_HEADER);
        if(StringUtils.hasText(bearToken) && bearToken.startsWith(BEARER_PREFIX)) {
            return bearToken.substring(7);
        }
        return null;
    }


}

- 토큰이 서버로 올 때 스프링 시큐리티가 검증을 해주는 필터다

- 올바른 헤더인지, 유효한 토큰인지 확인해줌

 

 

2. 기존 책에 있는 파일 수정

 

OAuthAttributes.java

: 얘는 거의 안 고친 것 같다. 책에서 추가로 카카오 관련만 추가해줬음.

 

CustomOAuth2UserService.java

: 얘도 대부분 유지했으나 세션에 등록하는

httpSession.setAttribute("user", new SessionUser(user)); //SessionUser: 세션에 사용자 정보 저장하기 위한 dto

이 부분만 지운 것 같다.

이 외에는 추가적으로 다른 방식에 맞게 수정하면 될 것 같다.

 

 

SecurityConfig.java

		.and()
                    .sessionManagement()
                    .sessionCreationPolicy(SessionCreationPolicy.STATELESS) //세션 사용 안 함
                .and()
                    .addFilterBefore(new JwtAuthenticationFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class)
                    .oauth2Login()
                    .successHandler(loginHandler)
                    .userInfoEndpoint()// 로그인 성공 이후 사용자 정보 가져올 때
                    .userService(customOAuth2UserService); //소셜 로그인 성공 후 인터페이스 구현체 등록(ex. sns에서 가져오고 싶은 사용자 정보 기능 명시 가능

 

- 세션 사용 안 함

- 로그인 요청 들어오면 -> 필터링 -> 로그인 -> 사용자 정보 가져와서 customOAuth2UserService에서 가공 -> 성공하면 loginHandler로 가서 리액트로 리다이렉트

 

 


 

3. login 성공 후 리다이렉트

를 시켜주는 곳이 loginHandler다.

 

loginHandler.java

import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;


@RequiredArgsConstructor
@Component
public class LoginHandler extends SimpleUrlAuthenticationSuccessHandler {

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

        //authentication: 인증 토큰

        //login 성공한 사용자 목록
        OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
        String email = (String) oAuth2User.getAttributes().get("email");

        Long userId = userRepository.findByEmailAuth(email)
                .orElseThrow(() -> new IllegalArgumentException
                        ("해당 유저가 없습니다. emailAuth = " + email))
                .getUserId();

        // 토큰 발행
        JwtTokenResponse jwtToken = jwtUtil.generateToken(userId);

        // 리프레시 토큰 저장
        authService.saveRefreshToken(userId, jwtToken);

        // 인증 코드를 담은 리다이렉트 uri 생성
        String url = makeRedirectUrl(jwtToken.getAccessToken(), jwtToken.getRefreshToken());

        if (response.isCommitted()) {
            logger.debug("응답이 이미 커밋된 상태입니다. " + url + "로 리다이렉트하도록 바꿀 수 없습니다.");
            return;
        }
        getRedirectStrategy().sendRedirect(request, response, url);
    }

    private String makeRedirectUrl(String accessToken, String refreshToken) {
        return UriComponentsBuilder.newInstance()
                .scheme("http")
                .host(CLIENT_HOST)
                .port(3000)
                .path("/oauth2/redirect")
                .queryParam("accessToken", accessToken)
                .queryParam("refreshToken", refreshToken)
                .build().toUriString();
    }
}

- 로그인이 성공하면 리액트에게 accessToken과 refreshToken을 쿼리 파라미터에 담아서 리다이렉트 시켜준다

- CustomOAuth2User.java의

return new DefaultOAuth2User(
        Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey())),
        attributes.getAttributes(),
        attributes.getNameAttributeKey()
);

에서 OAuth2User로 올려준 부분에서 다시 이메일을 가지고 온다.

- 해당 유저의 pk를 찾아서 토큰을 만들고 refreshToken은 디비에 저장한다

 

 


 

 

4. accessToken 관리

 

얘를 관리해주기 위해 재발급 용 주소도 필요하다.

 

AuthController.java

import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import java.nio.file.AccessDeniedException;

@CrossOrigin("*")
@RequiredArgsConstructor
@RestController
@RequestMapping("/auth")
public class AuthController {

    private final AuthService authService;

//    @GetMapping("/reissue")
//    public JwtTokenResponse reissue(HttpServletRequest request, @CookieValue(name = "RefreshToken") Cookie cookie)
//            throws AccessDeniedException {
//        String accessToken = request.getHeader("Authorization");
//        String refreshToken = cookie.getValue();
//
//        return authService.reissue(accessToken, refreshToken);
//    }

    @GetMapping("/refresh")
    public String refresh(HttpServletRequest request, @CookieValue(name = "RefreshToken") Cookie cookie)
            throws AccessDeniedException {
        String refreshToken = cookie.getValue();
        return authService.refresh(refreshToken);
    }
}

- 쿠키에 저장된 refreshToken을 가져와 디비와 비교한다. (refresh()라는 함수로 서비스 단에 구성)

- 원래 refreshToken 재발급 용 api 도 만드려 했으나... 그냥 재로그인 시키기로 했다. 이게 더 깔끔하고 만료기간이 2주인데 이정도면 로그아웃 시키는 게 더 좋은 것 같다.

 

AuthService도 만들었는데 얘는 뭐 간단해서 알아서 구현하면 될 것 같다.

필요한 메소드들 만들어서 여기로 넣으면 될 듯!

 


 

5. 다른 api에서 이용

 

원래 세션에서 추출했던 것 마냥 토큰에서 추출하면 된다.

@GetMapping("/")
public String get(HttpServletRequest request) {
    String email = authService.getEmailFromHeader(request);
    return email;
}

- 이런식으로 request에서 Authorization 헤더에서 토큰 뽑고, 파싱해서 담아논 유저 정보 잡아내면 된다.

 

 


책만 이용해서 속성으로 스프링을 배웠더니 이렇게 응용하는 게 너무 힘들었다.

리액트랑 연결하고 문제 있다는 걸 알고 적용하는데 하루 꼬박 썼다.

하루종일 jwt 이해하고 코드 짜고...

백엔드의 꽃은 보안인 것 같다

 

우선 저는 꽃 싫어하긴 해요

 

 

 


 

 

* 참고 블로그

https://sudo-minz.tistory.com/78

 

스프링부트x리액트 '카카오 로그인 하기' (JWT+OAuth2) [2]

스프링부트x리액트 카카오 로그인 구현하기 (JWT+OAuth2) 해당 포스팅에 대한 구조, 이론 정리는 이전 게시글 에 있습니다. 스프링부트 카카오 로그인 하기 (JWT+OAuth2) [1] 이번 포스팅은 카카오로그

sudo-minz.tistory.com

https://velog.io/@codesusuzz/Spring-Boot-JWT-%EC%86%8C%EC%85%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%A0%95%EB%B3%B5%EA%B8%B0-2-%EC%84%9C%EB%B9%84%EC%8A%A4%EC%BB%A8%ED%8A%B8%EB%A1%A4%EB%9F%AC-%EA%B5%AC%ED%98%84

 

[Spring Boot] JWT + 소셜 로그인 정복기 (2) - 서비스/컨트롤러 구현

이 글은 이틀간의 논스탑바보짓밤샘에러해결쇼의 결과물에 대한 기록입니다. 제가 진행하고 있는 프로젝트를 기준으로 작성했으며, 더 나은 로직이 (⚡️얼마든지⚡️) 있을 수 있으므로 참고

velog.io

https://junuuu.tistory.com/415

 

Spring Security와 Oauth 2.0으로 로그인 구현하기(SpringBoot + React)

(1) OAuth2.0이란? (2) Spring Security와 OAuth 2.0으로 로그인 구현하기(SpringBoot + React) (3) Spring Security OAuth 2.0 단위테스트 (4) Spring Security가 OAuth 로그인을 처리하는 방법 이해하는데 도움이 되는 개념 - Spri

junuuu.tistory.com

https://inpa.tistory.com/entry/WEB-%F0%9F%93%9A-Access-Token-Refresh-Token-%EC%9B%90%EB%A6%AC-feat-JWT

 

[WEB] 📚 Access Token & Refresh Token 원리 (feat. JWT)

Access Token과 Refresh Token 이번 포스팅에서는 기본 JWT 방식의 인증(보안) 강화 방식인 Access Token & Refresh Token 인증 방식에 대해 알아보겠다. 먼저 JWT(Json Web Token) 에 대해 잘 모르는 독자들은 다음 포

inpa.tistory.com