[SpringBoot] 로그인/세션 등 리액트 연동 문제 해결(2) JWT + 스프링 + 카카오/구글/네이버 로그인, 세션, 스프링 부트와 AWS로 혼자 구현하는 웹 서비스
아무튼 전 편에 이어서 이야기를 해보자면, 리액트의 주소와 스프링의 주소가 달라서 문제들이 생겼었다.
마지막 문제는 세션 문제였다. 그래서 과감히 세션을 버리고 토큰 방식으로 옮겼다.
* 방식
나는 스프링 시큐리티를 이용했기 때문에 프론트에서 인가 코드를 직접 이용하고 그러지는 않았다.
스프링이 로그인 과정은 다 해준다.
로그인이 되면 백엔드에서 정보 따서 토큰화 한다음 프론트로 주고,
프론트에서는 토큰 저장해놨다가 백엔드로 보내주고
백엔드는 유효한지 체크해주는 것!
내가 작성한 플로우는
- 클라이언트는 로그인을 시도한다 (/oauth2/authorization/google)
- 스프링에서 로그인을 한 다음 사용자 정보를 백엔드로 넘겨준다
- 백엔드는 사용자 정보를 좋게 JWT에 담아서 얘를 리액트에게 리다이렉트 시킨다
- 리액트는 스토리지에 담아뒀다가 얘를 헤더에 담아 인증을 받는다
아래는... 코드를 담았는데 솔직히 시간에 쫓기느라 좀 대충 짰다.
이것저것 고려하지 않은 코드이니 그냥 돌아간다~만 봐주시길.
서비스, 도메인 이런 구분 제대로 못한 거 같아서 부끄럽다.
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
[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
[WEB] 📚 Access Token & Refresh Token 원리 (feat. JWT)
Access Token과 Refresh Token 이번 포스팅에서는 기본 JWT 방식의 인증(보안) 강화 방식인 Access Token & Refresh Token 인증 방식에 대해 알아보겠다. 먼저 JWT(Json Web Token) 에 대해 잘 모르는 독자들은 다음 포
inpa.tistory.com