Spring Security, OAuth2.0 Client, JWT 로그인 구현하기 - 2
0. 지난 내용
저번 포스트에서 OAuth2.0 Client 의 기능이 뭔지
어떻게 OAuth 를 간단하게 구현할 수 있는지 알아 보았다.
이번에는 OAuth 로그인 이후의 과정을 알아보자 !
1. 이번 목표
저번 포스트에서 OAuth 로그인을 통해 유저 정보를 받아 왔다.
이 정보를 이용해서 우리 서버와 계속 통신할 수 있도록 JWT 토큰을 발급해보자
JWT 가 왜 필요한지 간단하게 설명하자면
HTTP, HTTP 통신을 기반으로 하는 REST API 는 무상태성 통신을 지향하기 때문에
매 요청마다 서버는 클라이언트가 누구인지 알 수 없다.
이러한 단점을 해결하고 각 요청마다 요청한 사람이 누구인지 인증하기 위해 JWT, Session 등의 기술을 사용한다.
그 중에서 이번에는 보편적으로 이용되는 JWT 토큰 방식을 이용해서 인증서버를 구현해보자
2. 구현 흐름
구현하기 전에 큰 그림을 그려보자
이번에 구현할 Service 는 총 2개 이다.
- OAuth2SuccessHandler
- TokenService
OAuth2SuccessHandler 는 OAuth2Service 요청을 성공한 후의 과정을 처리한다.
AuthenticationSuccessHandler 인터페이스를 구현함으로 구현할 수 있다.
이곳에서 JWT Token 을 발급하는 TokenService 를 주입 받아서
OAuth 로그인 성공 후에 카카오 구글 등의 서비스로부터 받은 토큰이 아닌
우리 서버와 통신하기 위한 JWT 토큰을 발급할 것이다.
먼저 TokenService 부터 구현을 해보자.
3. TokenService
3-1. jjwt 라이브러리 의존성 추가
먼저 build.gradle 파일에 JWT 구현을 위해 필요한 라이브러리를 설치해 줍시다.
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
3-2. JWT 설정 파일 application.yaml 에 추가해주기
저는 yaml 형식이 더 읽기 편한 것 같아서 주로 yaml 형식을 사용합니다.
여기에 JWT 관련한 설정값을 추가해 줍시다.
저는 refresh_token 과 access_token 의 key 값을 다르게 가져갈 생각이기 때문에 key 값을 두 개 설정하겠습니다.
그리고 토큰의 만료시간을 설정해 줍시다.
jwt:
acceess_token_secret_key: supersecretsupersecretsupersecretsupersecretsupersecretsupersecretsupersecret
refresh_token_secret_key: supersecretsupersecretsupersecretsupersecretsupersecretsupersecretsupersecret
expiration_in_seconds: 86400000
이 값은 뒤에 나올 TokenService 에서 꺼내서 사용해 주도록 합시다 !
3-3. TokenService 구현
본격적으로 JWT 토큰을 발급해줄 TokenService 를 구현해 봅시다.
@Service
@RequiredArgsConstructor
public class TokenService {
@Value("${jwt.acceess_token_secret_key}")
private String ACCESS_TOKEN_SECRET_KEY;
@Value("${jwt.refresh_token_secret_key}")
private String REFRESH_TOKEN_SECRET_KEY;
@Value("${jwt.expiration_in_seconds}")
private Long EXPIRATION;
private Key ENCODED_ACCESS_KEY;
private Key ENCODED_REFRESH_KEY;
private final String EMAIL = "email";
private final String USER_ID = "userId";
먼저 상수값 세팅입니다.
application.yaml 에서 설정한 acceess_token_secret_key, refresh_token_secret_key, expiration_in_seconds 값을 꺼내어 ACCESS_TOKEN_SECRET_KEY, REFRESH_TOKEN_SECRET_KEY, EXPIRATION 에 저장해 줍니다.
JWT 토큰은 key 값을 Base64 로 인코딩 해서 사용하기 때문에
인코딩한 키 값을 저장할 ENCODED_ACCESS_KEY, ENCODED_REFRESH_KEY 변수를 선언해 줍니다.
EMAIL, USER_ID 는 KakaoUser 정보를 파싱하기 위한 상수값입니다.
@PostConstruct
public void init() {
byte[] accessKeyBytes = Decoders.BASE64.decode(ACCESS_TOKEN_SECRET_KEY);
this.ENCODED_ACCESS_KEY = Keys.hmacShaKeyFor(accessKeyBytes);
byte[] refreshKeyBytes = Decoders.BASE64.decode(REFRESH_TOKEN_SECRET_KEY);
this.ENCODED_REFRESH_KEY = Keys.hmacShaKeyFor(refreshKeyBytes);
}
인코딩 키 값 세팅입니다.
사용자가 입력한 key 값을 Base64 로 인코딩하기 위한 함수입니다.
@PostConstruct 데코레이터를 활용해서 TokenService 객체가 생성된 직후에 자동으로 실행되게 해줍시다.
Base64로 인코딩한 Key 값을 위에서 선언한 ENCODED_ACCESS_KEY, ENCODED_REFRESH_KEY 에 저장해 줍시다.
public String createAccessToken(Long userId, String email) {
return createToken(userId, email, false);
}
public String createRefreshToken(Long userId, String email) {
return createToken(userId, email, true);
}
private String createToken(Long userId, String email, boolean isRefreshToken) {
return Jwts.builder()
.claim(USER_ID, userId)
.claim(EMAIL, email)
.signWith(isRefreshToken ? ENCODED_REFRESH_KEY : ENCODED_ACCESS_KEY, SignatureAlgorithm.HS256)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION * 1000))
.compact();
}
토큰 생성 부분입니다.
accessToken, refreshToken 생성 로직은 비슷하니 createToken을 공통 private 함수로 분리하고 public 으로 createAccessToken, createRefreshToken 으로 분리해 줍시다.
createToken을 public 으로 노출해도 되지만 호출하는 쪽에서 flag 성 변수인 isRefreshToken 으로 어떤 토큰인지 판별해야 하기 때문에 명시적이지 않다고 생각해 accessToken, refreshToken 생성 함수를 따로 분리했습니다.
그리고 추가적으로 둘은 Expiration 또한 달라질 수 있기 때문에 분리하는게 더 맞다고 생각합니다.
이제 TokenService 와 토큰 생성 함수를 구현했으니 OAuth@SuccessHandler로 넘어갑시다.
3-4. 전체 코드
@Service
@RequiredArgsConstructor
public class TokenService {
@Value("${jwt.acceess_token_secret_key}")
private String ACCESS_TOKEN_SECRET_KEY;
@Value("${jwt.refresh_token_secret_key}")
private String REFRESH_TOKEN_SECRET_KEY;
@Value("${jwt.expiration_in_seconds}")
private Long EXPIRATION;
private Key ENCODED_ACCESS_KEY;
private Key ENCODED_REFRESH_KEY;
private final String EMAIL = "email";
private final String USER_ID = "userId";
@PostConstruct
public void init() {
byte[] accessKeyBytes = Decoders.BASE64.decode(ACCESS_TOKEN_SECRET_KEY);
this.ENCODED_ACCESS_KEY = Keys.hmacShaKeyFor(accessKeyBytes);
byte[] refreshKeyBytes = Decoders.BASE64.decode(REFRESH_TOKEN_SECRET_KEY);
this.ENCODED_REFRESH_KEY = Keys.hmacShaKeyFor(refreshKeyBytes);
}
public String createAccessToken(Long userId, String email) {
return createToken(userId, email, false);
}
public String createRefreshToken(Long userId, String email) {
return createToken(userId, email, true);
}
private String createToken(Long userId, String email, boolean isRefreshToken) {
return Jwts.builder()
.claim(USER_ID, userId)
.claim(EMAIL, email)
.signWith(isRefreshToken ? ENCODED_REFRESH_KEY : ENCODED_ACCESS_KEY, SignatureAlgorithm.HS256)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION * 1000))
.compact();
}
}
4. OAuth2SuccessHandler
4-1. 구현
Spring Security 는 인증, 인가에 필요한 많은 부분을 추상화 해줍니다.
AuthenticationSuccessHandler interface 의 onAuthenticationSuccess 함수를 Overide 하게 되면
Authentication 에 성공한 후의 로직을 구현할 수 있습니다.
우리는 OAuth 인증이 성공한 후에 DB에 회원 정보를 삽입하는 것에 성공했으니
OAUth2SuccessHandler 에서는 TokenService 를 이용해서 JWT Token을 발급해 봅시다.
@Component
@Slf4j
@RequiredArgsConstructor
public class OAuth2SuccessHandler implements AuthenticationSuccessHandler {
private final TokenService tokenService;
private final UserService userService;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
// OAuth2UserService 에서 생성한 DefaultOAuth2User 정보를 authentication 에서 꺼낼 수 있다.
KakaoUser kakaoUser = new KakaoUser((OAuth2User) authentication.getPrincipal());
// accessToken 생성 및 쿠키설정
Cookie accessTokenCookie = getAccessTokenCookie(kakaoUser);
accessTokenCookie.setPath("/");
accessTokenCookie.setHttpOnly(true);
// refreshToken 생성 및 쿠키설정
String refreshToken = tokenService.createRefreshToken(kakaoUser.getId(), kakaoUser.getEmail());
Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken);
refreshTokenCookie.setPath("/");
refreshTokenCookie.setHttpOnly(true);
// refreshToken 은 DB에 저장해주자 ! ( Redis에 저장하는 방법도 있지만 RDBMS를 선택했다.)
// 이 부분은 어렵지 않으니 직접 구현하시면 됩니다.
userService.updateRefreshTokenByEmail(kakaoUser.getEmail(), refreshToken);
// response 객체에 쿠키를 세팅해주자.
response.addCookie(refreshTokenCookie);
response.addCookie(accessTokenCookie);
// 로그인 페이지로 리다이렉트 시켜주자
response.sendRedirect("/login");
}
private Cookie getAccessTokenCookie(KakaoUser kakaoUser) {
String token = tokenService.createAccessToken(kakaoUser.getId(), kakaoUser.getEmail());
return new Cookie("accessToken", token);
}
}
여기까지 설정하게 되면
accessToken, refreshToken 을 cookie 에 담아서 /login 으로 리다이렉트하게 됩니다.
한 번 서버를 실행시켜서 결과를 확인해 봅시다 !
4-2. 결과
읭 ???
Cookie 에 토큰이 설정되지도 않고 로그인도 실패했습니다.
왜 그럴까요 ?
먼저 유저 정보가 제대로 삽입됐는지 DB를 살펴봅시다.
데이터베이스에는 데이터가 정상적으로 삽입 되었습니다.
SuccessHandler 가 동작하지 않은 것 같습니다.
4-3. SecurityConfig 등록
네 맞습니다.
Spring Security 는 구현한 것을 설정에 항상 등록해 줘야합니다.
우리가 만든 SuccessHandler 를 등록하지 않았으니 동작하지 않는게 당연하겠네요.
SpringSecurity에 구현한 Handler 를 등록해 줍시다.
@Configuration
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final OAuth2UserService oAuth2UserService;
private final OAuth2SuccessHandler oAuth2SuccessHandler;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(sessionManagement ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.headers(headers ->
headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)
);
http
.oauth2Login(config -> config
.userInfoEndpoint(endpointConfig -> endpointConfig
.userService(oAuth2UserService))
// 이 부분에 successHandler 옵션을 추가해 줍시다.
// 추후에 failHandler 도 같은 방법으로 등록해 주시면 됩니다.
.successHandler(oAuth2SuccessHandler)
);
return http.build();
}
}
5. 최종 결과
자 이제 SuccessHandler 등록도 마쳤으니 결과를 확인해 봅시다.
다시 /login 페이지로 redirect 시켰기 때문에 화면은 변화가 없지만 Cookie 를 확인해보면 accessToken 과 refreshToken 이 잘 설정된 것을 확인할 수 있다.