Access token 발급
- 사용자가 서버에 접근한다.
- JwtAuthenticationFilter 가 JwtTokenProvider를 통해 토큰을 검증한다.
- 인증 과정에서 토큰이 비정상적이면 JwtAuthenticationEntryPoint 를 호출하고 인가 과정에서 문제가 생기면 JwtAccessDenidedHandler, 정상적이면 CustomOAuth2UserService 호출한다. 또한 정상적일경우 SecurityContextHolder에 유저정보가 담김(filter 참고)
- 토큰이 정상적이면 oauth 로그인 후처리 과정으로 CustomOAuth2UserService 호출 됨
- 위에서 OAuth2 로그인이 성공하면 SuccessHandler 실패하면 FailureHandler 가 호출된다.
Refresh token 발급
jwt는 토큰 자체에 정보를 담고 있어서 보안이 매우 취약하다.
이를 보완하기 위해서 사용하는 것이 refresh token!
→ AccessToken의 유효기간은 매우 짧게 Refresh Token의 유효기간은 길게 해주는 것이 포인트!
→ AccessToken이 만료됐을 시 refresh token 으로 재발급 받을 수 있다. 이 때 refresh token은 만료가 되지 않았어도 새로 발급해주면 보안 상 더 좋다.
장점
- Access token의 유효기간을 짧게 잡아 중간 탈취 방지 효과를 얻을 수 있다. → 만약 중간에 탈취 당하더라도 유효기간이 짧아 사용할 수 있는 기간이 줄어들음!
- Access Token 의 유효기간이 짧아도 Refresh Token 이 만료될때까지 추가적인 로그인 하지 않아도 됨! → 마치 세션이 유지되는 듯 한 효과
기존의 Spring security 에서는 WebSecurityConfigurerAdapter를 상속받아 configure 메소드를 오버라이딩 해서 구현했었다면, 현재는 WebSecurityConfigurerAdapter가 deprecated , 즉 사용되지 않아 다른 방법을 사용해야한다.
→ 방법 : SecurityFilterChain를 Bean 으로 등록해서 사용해야 한다.
Spring Security.java
package com.smtd.smtdApi.config;
import com.smtd.smtdApi.security.jwt.JwtAccessDenidedHandler;
import com.smtd.smtdApi.security.jwt.JwtAuthenticationEntryPoint;
import com.smtd.smtdApi.security.jwt.JwtAuthenticationFilter;
import com.smtd.smtdApi.security.oauth.OAuth2FailureHandler;
import com.smtd.smtdApi.security.oauth.OAuth2SuccessHandler;
import com.smtd.smtdApi.security.oauth.CustomOAuth2UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration // IoC 빈(bean)을 등록
@EnableWebSecurity // 필터 체인 관리 시작 어노테이션
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true) // 특정 주소 접근시 권한 및 인증을 위한 어노테이션 활성화
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final CustomOAuth2UserService oAuth2UserService;
private final OAuth2SuccessHandler oAuth2SuccessHandler;
private final OAuth2FailureHandler oAuth2FailureHandler;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDenidedHandler jwtAccessDenidedHandler;
@Bean
public WebSecurityCustomizer configure() {
return (web) -> web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations())
.antMatchers("/h2-console/**", "/vendor/**", "/img/**", "/favicon.ico", "/docs/**");
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/oauth2/**", "/login", "/auth/**").permitAll()
.antMatchers("/admin/**").access("hasRole('ADMIN')")
.anyRequest().authenticated()
.and()
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.httpBasic().disable() // base auth off
.cors()
.and()
.csrf().disable() // csrf off
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.formLogin().disable()
.httpBasic().disable()
.oauth2Login()
.authorizationEndpoint() // 소셜 로그인
.baseUri("/oauth2/authorization")
.and()
.redirectionEndpoint().baseUri("/oauth2/code/*") //소셜 로그인 후 redirect url
.and()
.userInfoEndpoint()
.userService(oAuth2UserService) // 구글 로그인이 완료된 뒤의 후처리가 필요함. Tip. 코드를 받는게 아님( 액세스 토큰 + 사용자 프로필 정보를 한방에 받음 )
.and()
.successHandler(oAuth2SuccessHandler) // 성공 핸들러
.failureHandler(oAuth2FailureHandler); // 실패 핸들러
http.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDenidedHandler);
return http.build();
}
}
JwtAuthenticationFilter.java
package com.smtd.smtdApi.security.jwt;
import com.smtd.smtdApi.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
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
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider tokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = parseToken(request);
// 토큰 유효성 검사
// 정상이면 해당 토큰으로 Authentication 가져와서 Security 저장
if(StringUtils.hasText(token) && tokenProvider.validateToken(token)){
Authentication authentication = tokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
logger.info(authentication.getName() + "의 인증정보 저장");
} else {
logger.info("유효한 토큰이 없습니다.");
}
filterChain.doFilter(request, response);
}
// 토큰 꺼내오기
private String parseToken(HttpServletRequest request){
String bearerToken = request.getHeader("Authorization");
if(StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")){
return bearerToken.substring(7);
}
return null;
}
}
로그인 시 위의 필터에서 token을 꺼내와서 유효성 체크를 진행한다.
Authentication authentication = tokenProvider.getAuthentication(token)
→ 위의 코드는 인증이 끝나고 SecurityContextHolder.getContext()에 등록 될 Authentication 객체를 받음
( 자세한건 아래 JwtTokenProvider 확인 )
JwtTokenProvider.java
package com.smtd.smtdApi.security.jwt;
import com.smtd.smtdApi.repository.MemberRepository;
import com.smtd.smtdApi.security.CustomUserDetails;
import io.jsonwebtoken.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;
@Log4j2
@RequiredArgsConstructor
@Component
public class JwtTokenProvider {
@Value("${app.auth.token.secret-key}")
private String SECRET_KEY;
public static final Long ACCESS_TOKEN_EXPIRATION_TIME = 1000L * 60 * 60;
private final Long REFRESH_TOKEN_EXPIRE_LENGTH = 1000L * 60 * 60 * 24 * 7;
private static final String AUTHORITIES_KEY = "role";
private final MemberRepository memberRepository;
// access token 생성
public String createAccessToken(Authentication authentication) {
return this.createToken(authentication, ACCESS_TOKEN_EXPIRATION_TIME);
}
// refresh token 생성
public String createRefreshToken(Authentication authentication){
String token = this.createToken(authentication, REFRESH_TOKEN_EXPIRE_LENGTH);
CustomUserDetails user = (CustomUserDetails) authentication.getPrincipal();
String email = user.getEmail();
memberRepository.updateRefreshToken(email, token);
return token;
}
public String createToken(Authentication authentication, long tokenValidTime){
CustomUserDetails user = (CustomUserDetails) authentication.getPrincipal();
Date now = new Date();
Date expireDate = new Date(now.getTime() + tokenValidTime);
String userId = user.getName();
String role = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
String token = Jwts.builder()
.setSubject(userId)
.claim(AUTHORITIES_KEY, role)
.setIssuer("smtd")
.setIssuedAt(new Date())
.setExpiration(expireDate)
.signWith(SignatureAlgorithm.HS256, SECRET_KEY).compact();
return token;
}
// 인증 후 객체 부여
public Authentication getAuthentication(String token) {
Claims claims = getClaims(token);
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new).collect(Collectors.toList());
CustomUserDetails user = new CustomUserDetails(1L, claims.getSubject(), authorities);
return new UsernamePasswordAuthenticationToken(user, "", authorities);
}
// 토큰 복호화
public Claims getClaims(String token) {
Claims claims = Jwts.parser()
.setSigningKey(SECRET_KEY)
.parseClaimsJws(token)
.getBody();
return claims;
}
// 토큰 유효성 검사
public Boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token);
return true;
} catch (ExpiredJwtException e) {
log.info("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
log.info("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalStateException e) {
log.info("JWT 토큰이 잘못되었습니다");
} catch (SignatureException e) {
log.info("유효하지 않은 JWT signature 입니다.");
} catch (MalformedJwtException e) {
log.info("유효하지 않은 JWT 토큰입니다.");
}
return false;
}
}
만약 위의 과정을 걸쳐 인증이 완료 되면 successHandler 를 거쳐서 accessToken 과 refreshToken이 발급된다. 아래 코드에서는 임시로 발급되는것을 확인 하기 위해 queryParam 으로 발급해주었다. 또한 refresh token은 DB에 저장해 주었다.
OAuth2SuccessHandler.java
package com.smtd.smtdApi.security.oauth;
import com.smtd.smtdApi.exception.ApiException;
import com.smtd.smtdApi.exception.ErrorCode;
import com.smtd.smtdApi.security.jwt.JwtTokenProvider;
import com.smtd.smtdApi.utils.CookieUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
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.ServletException;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URI;
import java.util.List;
import java.util.Optional;
import static com.smtd.smtdApi.security.oauth.HttpCookieOAuth2AuthorizationRequestRepository.REDIRECT_URI_PARAM_COOKIE_NAME;
@Log4j2
@Component
@RequiredArgsConstructor
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
@Value("${app.oauth2.authorizedRedirectUris}")
private List<String> redirectUris;
private final JwtTokenProvider jwtTokenProvider;
private final HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
String targetUrl = determineTargetUrl(request, response, authentication);
if(response.isCommitted()){
logger.debug("response has already been commmited. unable to redirect to " + targetUrl);
return;
}
clearAuthenticationAttributes(request, response);
getRedirectStrategy().sendRedirect(request, response, targetUrl);
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
super.onAuthenticationSuccess(request, response, authentication);
}
protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response, Authentication authentication){
Optional<String> redirectUri = CookieUtils.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME).map(Cookie::getValue);
if(redirectUri.isPresent() && !isAuthorizedRedirectUri(redirectUri.get())){
throw new ApiException(ErrorCode.NO_EMAIL);
}
String targetUri = redirectUri.orElse(getDefaultTargetUrl());
String accessToken = jwtTokenProvider.createAccessToken(authentication);
String refreshToken = jwtTokenProvider.createRefreshToken(authentication);
return UriComponentsBuilder.fromUriString(targetUri)
.queryParam("error", "")
.queryParam("accessToken", accessToken)
.queryParam("refreshToken", refreshToken)
.build().toUriString();
}
protected void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) {
super.clearAuthenticationAttributes(request);
httpCookieOAuth2AuthorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
}
private boolean isAuthorizedRedirectUri(String uri){
URI clientRedirectUri = URI.create(uri);
for(String redirectUri : redirectUris){
URI authorizedUri = URI.create(redirectUri);
if(authorizedUri.getHost().equalsIgnoreCase(clientRedirectUri.getHost())
&& authorizedUri.getPort() == clientRedirectUri.getPort()){
return true;
}
}
return false;
}
}
로그인 완료 후 url 로 accessToken을 전달 받았다( header에 넣어주는 것을 권장 함 )
해당 토큰을 미리 만들어 둔 test 에 담아서 보내면 Hello world를 response 받을 수 있다.
만약 잘못된 토큰을 담아 보내면 401 error 가 난다.
자 그렇다면 Access 토큰 만료 시 재발급을 어떻게 해야 할까??
필자는 access token 재발급시와 동시에 refresh token도 업데이트 시켜주었다.
좀 더 쉬운 테스트를 위해 accessToken 과 refreshToken을 전달 받아 해당 accessToken 의 만료 유무를 체크하고 만료 되었으면
새로운 token을 발급 , 아직 만료 전이면 null 을 return 하도록 해보았다.
AuthService.java
package com.smtd.smtdApi.service;
import com.smtd.smtdApi.repository.MemberRepository;
import com.smtd.smtdApi.security.CustomUserDetails;
import com.smtd.smtdApi.security.jwt.JwtTokenProvider;
import io.jsonwebtoken.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
import java.util.Date;
@Log4j2
@Service
@RequiredArgsConstructor
public class AuthService {
@Value("${app.auth.token.secret-key}")
private String SECRET_KEY;
private final MemberRepository memberRepository;
private final JwtTokenProvider tokenProvider;
public String refreshToken(String accessToken, String refreshToken) {
String savedToken = "";
try {
tokenProvider.getAuthentication(accessToken);
} catch (ExpiredJwtException e) {
Authentication authentication = tokenProvider.getAuthentication(refreshToken);
CustomUserDetails user = (CustomUserDetails) authentication.getPrincipal();
String email = user.getEmail();
savedToken = memberRepository.getRefreshTokenByEmail(email);
if (savedToken.equals(refreshToken)) {
Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(refreshToken);
//refresh 토큰의 만료시간이 지나지 않았을 경우, 새로운 access 토큰을 생성합니다.
Date exp = Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(refreshToken).getBody().getExpiration();
if (!exp.before(new Date())) {
tokenProvider.createRefreshToken(authentication);
return tokenProvider.createAccessToken(authentication);
}
}
}
return null;
}
}
tokenProvider.getAuthentication(accessToken)
: accessToken이 만료 되었으면 ExpiredJwtException을 터뜨릴것이다.
그렇다면 catch 에서 refreshToken 으로 계정 정보를 가지고 있는 Authentication 객체를 가져온다.
savedToken = memberRepository.getRefreshTokenByEmail(email)
: 유저의 email 정보를 가져와 해당 이메일의 refresh token을 DB에서 검색해서 가져온다.
Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(refreshToken).getBody().getExpiration()
: refreshToken의 만료시간, 전달받은 refreshToken과 DB에서 가져온 값이 일치하면 refreshToken도 만료시간(exp)을 체크한다.
만료 시간이 지나지 않았으면 accessToken은 재발급 해 반환해주고 refreshToken은 새로 update 시킨다.
'JAVA' 카테고리의 다른 글
JUnit5 기본 annotation (0) | 2022.12.14 |
---|---|
Homebrew mysql 설치 (0) | 2022.12.07 |
spring boot 프로젝트 생성과 사용 이유 (0) | 2022.10.31 |
google oauth (0) | 2022.10.06 |
LocalDate 와 LocalTime (0) | 2022.08.25 |