프로젝트를 진행하며 추가로 공부하게 된 부분을 정리
- 기존 세션방식 security에서는 formLogin처럼 로그인 페이지를 설정하고 클라이언트가 oauth로그인을 하고 서버에게 accessCode를 주면 서버에서 OAuth Provider에게 AccessCode를 들고 토큰을 발급 받아 이를 통해 자원을 요청하고 받아 처리를 하고 세션에 인증 정보를 저장했다.
- 하지만, JWT는 서버에서 세션을 STATELESS하게 유지한다. 즉, 해당 정보를 유지하지 않고 클라이언트에게 정보 유지를 맡기는 것
- JWT로 바뀐다고해서 OAuth의 인증 방식이 바뀌는 것은 아님
구현코드
SecurityConfig, CustomOAuthSuccessHandler
.and().oauth2Login()
.successHandler(oAuthSuccessHandler)
.userInfoEndpoint().userService(customOAuth2UserService)
-----------------------------------------------------------
(CustomOAuthSuccessHandler)
@Override
@Transactional
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
String accessToken = tokenProvider.createToken(authentication,"Access");
String refreshToken = tokenProvider.createToken(authentication,"Refresh");
Optional<User> optUser = userRepository.findFirstByUsername(authentication.getName());
optUser.get().setRefreshToken(refreshToken);
response.addHeader(JwtFilter.AUTHORIZATION_HEADER, "Bearer " + accessToken);
String targetUrl= UriComponentsBuilder.fromUriString("http://localhost:3000/oauth2").queryParam("token",accessToken).queryParam("refresh>",refreshToken).build().toUriString();// String json=objectMapper.writeValueAsString(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
- 기존에 SSR방식에서 구현했던 것 처럼, loginPage를 설정하지 못한다
- handler를 따로 만들어서 구현한다.
- oAuthUserService는 세션방식과 같으니 넘어가고
- 성공 핸들러의 경우 AccessToken과 RefreshToken을 발행하여 프론트의 OAuth 처리 페이지의 쿼리 파라미터로 추가한 후 리다이렉트한다.
- 프론트에선, 해당 쿼리파라미터를 파싱해서 접근토큰은 로컬스토리지에, 갱신 토큰은 쿠키에 저장한다.
JWTFilter
public class JwtFilter extends GenericFilterBean {
private static final String[] whiteList={"/","/members/add","/auth/*","/css/*","/noAuthOk",
"/h2-console/*","/swagger-ui/*","/swagger-resources/*","/swagger-resources",
"/swagger-ui","/swagger-ui.html","/v3/api-docs"};
private static final Logger logger = LoggerFactory.getLogger(JwtFilter.class);
public static final String AUTHORIZATION_HEADER = "Authorization";
public static final String REFRESH_HEADER = "Refresh";
private TokenProvider tokenProvider;
public JwtFilter(TokenProvider tokenProvider) {
this.tokenProvider = tokenProvider;
}
//do Filter는 토큰의 인증정보를 현재 실행중인 security context에 저장하는 역할을 한다
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
String jwt = resolveToken(httpServletRequest,1);
String requestURI = httpServletRequest.getRequestURI();
//화이트리스트는 JWT체크를 하지 않음 (그냥 필터 pass)
if(isLoginCheckPath(requestURI)||requestURI.equals("/auth/logout")){
//AccessToken이 있고 유효한 경우 OK
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)==1) {
Authentication authentication = tokenProvider.getAuthentication(jwt,"ACCESS");
SecurityContextHolder.getContext().setAuthentication(authentication);
logger.debug("Security Context에 '{}' 인증 정보를 저장했습니다, uri: {}", authentication.getName(), requestURI);
} else if(StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)==2 ){
//AccessToken 만료
logger.debug("AccessToken Expired");
//도메인이 다른 경우 header에 넣어서 사용해야 함.
String refresh=resolveToken(httpServletRequest,2);
//도메인이 같은 경우 Cookie에서 RefreshToken으로 값 찾아서 바로 사용하면 됨
Optional<Cookie> optionalCookie = Arrays.stream(httpServletRequest.getCookies())
.filter(cookie -> cookie.getName().equals("RefreshToken")).findAny();
if(optionalCookie.isPresent()){
refresh=optionalCookie.get().getValue();
System.out.println("refresh by Cookie = " + refresh);
logger.info("cookie exist? :{}",httpServletRequest.getCookies().length);
}else{
refresh=resolveToken(httpServletRequest,2);
System.out.println("refresh by Header= " + refresh);
}
if(tokenProvider.validateToken(refresh)==1){
String name = tokenProvider.getUserName(refresh);
if(tokenProvider.refreshValidate(refresh,name)){
//Refresh Token 정상
logger.debug("Re-New Access Token");
Authentication authentication = tokenProvider.getAuthentication(refresh,"REFRESH");
String accessToken = tokenProvider.createToken(authentication, "Access");
logger.debug("New Access Token:{}",accessToken);
response.setHeader(AUTHORIZATION_HEADER,accessToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
}else{
//Refresh Token 사용불가
logger.debug("접근 토큰 만료, 리프레시 토큰 사용 불가, uri: {} url : {}", requestURI, httpServletRequest.getRequestURL());
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
}
}else{
//AccessToken,Refresh Token 사용불가
logger.debug("유효한 JWT 토큰이 없습니다, uri: {} url : {}", requestURI, httpServletRequest.getRequestURL());
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
}else{
logger.info("Received:{} is whiteList",requestURI);
}
filterChain.doFilter(servletRequest, servletResponse);
}
//request헤더에서 토큰을 받아오는 메소드
private String resolveToken(HttpServletRequest request, int type) {
//1 : access 2: refresh
logger.debug("in resolve token Type:{}",type);
String bearerToken;
if(type==1)
bearerToken = request.getHeader(AUTHORIZATION_HEADER);
else bearerToken=request.getHeader(REFRESH_HEADER);
System.out.println("bearerToken:"+bearerToken);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
private boolean isLoginCheckPath(String requestURI){
return !PatternMatchUtils.simpleMatch(whiteList,requestURI);
}
}
- Filter에 검사되지 않고 통과될 uri를 whitelist에 저장하고, PatternMatchUtils를 이용해 검증한다
- 화이트리스트에 존재하지 않는 uri를 대상으로 전달받은 request에서 Authorization헤더의 토큰을 파싱 후, 해당 토큰의 유효성을 검증한다
- 만약, 토큰이 유효하다면 SecurityContext에 인증정보를 저장한다
- 만약, AccessToken이 만료된 경우 RefreshToken이 존재하는지 확인한다.
- 쿠키에서 갱신 토큰을 꺼내 유효성을 검증한 후 유효하다면 접근 토큰을 새로 발급하여 Authorization헤더에 추가하고 SecurityContext에 인증 정보를 저장한다
- (코드에선 쿠키에 갱신토큰이 있으면 해당 값을 사용하고 아니면 Refresh헤더에서 값을 가져와 사용한다)
- AccessToken, RefreshToken 모두 유효하지 않다면 접근을 불허 한다. ( 필터단에서 요청을 거부한다.)
TokenProvider
@Component
@Slf4j
public class TokenProvider implements InitializingBean {
private final Logger logger = LoggerFactory.getLogger(TokenProvider.class);
private static final String AUTHORITIES_KEY = "auth";
private final String secret;
private final long accessTokenValidityMs;
private final long refreshTokenValidityMs;
private Key key;
private final UserRepository userRepository;
private final CustomUserDetailsService userDetailsService;
public TokenProvider(
@Value("${jwt.secret}") String secret,
@Value("${jwt.token-validity-in-seconds}") long tokenValidityInSeconds,
UserRepository userRepository, CustomUserDetailsService userDetailsService) {
this.secret = secret;
this.accessTokenValidityMs = tokenValidityInSeconds * 1000;
this.refreshTokenValidityMs=tokenValidityInSeconds*1000*60;
this.userRepository = userRepository;
this.userDetailsService = userDetailsService;
}
@Override
public void afterPropertiesSet() {
byte[] keyBytes = Decoders.BASE64.decode(secret);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
//인증 정보를 받아 토큰을 생성하는 메소드
public String createToken(Authentication authentication,String type) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
if(type.equals("Access")){
now+=this.accessTokenValidityMs;
Date validity = new Date(now);
log.info("authentication name : "+authentication.getName());
String compact = Jwts.builder()
.setSubject(authentication.getName())
.claim(AUTHORITIES_KEY, authorities)
.signWith(key, SignatureAlgorithm.HS512)
.setExpiration(validity)
.compact();
log.info("compact(jwt)={}",compact);
return compact;
}
else {
now += this.refreshTokenValidityMs;
Date validity = new Date(now);
log.info("authentication name : "+authentication.getName());
return Jwts.builder()
.setSubject(authentication.getName())
.signWith(key, SignatureAlgorithm.HS512)
.setExpiration(validity)
.compact();
}
}
//토큰을 받아 인증 정보를 리턴하는 메소드
public Authentication getAuthentication(String token, String type) {
//전달 받은 JWT token을 claim으로 decode한다
logger.debug("got Token:{}",token);
Claims claims = Jwts
.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
Collection<? extends GrantedAuthority> authorities;
if(type=="ACCESS"){
authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
log.info("claims . getsubject : " + claims.getSubject());
User principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, token, principal.getAuthorities());
}else{
log.info("claims.getSubject() = " + claims.getSubject());
UserDetails userDetails = userDetailsService.loadUserByUsername(claims.getSubject());
return new UsernamePasswordAuthenticationToken(userDetails,token,userDetails.getAuthorities());
}
}
public String getUserName(String token){
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().getSubject();
}
public int validateToken(String token) {
try {
log.info("now validate token");
Jws<Claims> claimsJws = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
log.info("claimsJws : " + claimsJws.getSignature()+"/"+claimsJws.getBody());
return 1;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
logger.info("잘못된 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
logger.info("만료된 JWT 토큰입니다.");
return 2;
} catch (UnsupportedJwtException e) {
logger.info("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalArgumentException e) {
logger.info("JWT 토큰이 잘못되었습니다.");
}
return 0;
}
public boolean refreshValidate(String refreshToken,String name){
if(this.validateToken(refreshToken)==1){
//DB에서 꺼내서 같은 객체 인지 확인
Optional<com.homebrewtify.demo.entity.User> opt = userRepository.findFirstByUsername(name);
if(opt.isPresent()){
return opt.get().getRefreshToken().equals(refreshToken);
}else{
return false;
}
}else return false;
}
}
- application 설정 파일에 저장한 secret과 token-validity-in-seconds를 이용하여 secret과 만료시간을 설정한다.
- AccessToken의 경우 유저의 권한 정보를 포함하여 토큰을 생성하고, RefreshToken의 경우 유저의 권한 정보를 포함하지 않고 접근 토큰에 비해 긴 만료시간을 가진다.
- 인증을 얻는 경우, 두 토큰 모두 같은 키로 claim을 얻고 AccessToken의 경우 권한 정보를 추가로 해독하여 인증에 추가한다
- 토큰의 유효성을 검증하는 메소드는 토큰이 정상적이라면 1 만료되었다면 2 나머지는 0을 리턴한다.
- 갱신 토큰의 유효성 검증 메소드는 DB에서 해당 유저의 refreshToken을 꺼내 같은지 확인한다.
SecurityUtil
public class SecurityUtil {
private static final Logger logger = LoggerFactory.getLogger(SecurityUtil.class);
private SecurityUtil() {}
public static Optional<String> getCurrentUsername() {
final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null) {
logger.debug("Security Context에 인증 정보가 없습니다.");
return Optional.empty();
}
String username = null;
if (authentication.getPrincipal() instanceof UserDetails) {
UserDetails springSecurityUser = (UserDetails) authentication.getPrincipal();
username = springSecurityUser.getUsername();
} else if (authentication.getPrincipal() instanceof String) {
username = (String) authentication.getPrincipal();
}
return Optional.ofNullable(username);
}
}
프로젝트를 진행하면서 궁금했던 부분
토큰을 어떻게 전달하지 ? 전달 받을 수 있나 ??
- 서버에서 client주소로 리디렉션을 진행하면 쿠키나 헤더에 저장해서 전달할 수 없다
- 서버의 OAuthSuccesHandler의 request, response는 클라이언트 origin이 아님.. 따라서 클라이언트의 주소로 리다이렉트를 시킬 때, 쿼리 파라미터로 accessToken, RefreshToken을 전달함
중복로그인 핸들링은 ??
- 로그인을 한 경우, 해당 유저의 localStorage엔 AccessToken이 존재하고, RefreshToken은 쿠키에 저장되어 있다.
- 따라서, 해당 저장소에 값이 존재하는 경우 프론트 단에서 로그인 페이지에 접근하지 못하게 설정
- 만약, 프론트의 처리를 뚫고 서버로 로그인을 시도하는 경우 새롭게 AccessToken, RefreshToken을 발급
로그아웃은 어떻게 해야 하나 ??
- 서버로 logout 을 전송, 서버에선 DB에 저장한 RefreshToken을 삭제하고 RefreshToken이란 쿠키를 삭제
- 서버로 응답을 정상적으로 받고, 프론트에서 localStorage, refresh Cookie를 삭제
- 만약, 재 로그아웃 시도 시 401 (로그아웃은 인증을 가진 사용자가 자신의 인증을 무효화 시켜달라는 요청이니 JwtFilter에서 토큰 검증을 실행하기 때문)
RefreshToken은 어떻게 전달해야 할까?
- 기본적으로 AccessToken은 인증 헤더를 통해 전달하고 LocalStorage에 저장하기에 CSRF공격에 안전하지만 XSS(악성 스크립트 삽입)을 통해 탈취당할 수 있다
- 떄문에 해당 토큰이 탈취되어도 보안에 큰 무리가 없게 하기 위해 AccessToken의 생명주기를 대폭 줄이고 RefreshToken으로 만료된 AccessToken을 새로 갱신한다.
- 쿠키에 httpOnly설정과 secure설정을 한다면 해당 쿠키를 공격자가 탈취 하거나 이용해서 AccessToken을 수령할 방법이 없어진다.(refreshToken은 AccessToken을 새로 갱신시키는 용도)
프로젝트를 진행하면서 겪은 에러들
Cors 에러 (Access Control Allow Origin 설정)
axios
.get("<http://localhost:8080/needAuth>", {
headers: {
Authorization: token,
Refresh: "Bearer " + refresh,
"Access-Control-Allow-Origin": `http://localhost:3000`,
"Access-Control-Allow-Credentials": "true",
},
withCredentials: true,
})
- 단순 요청 : GET,HEAD,POST중 하나의 메소드와 Accept,Accept-Language ..등 헤더중 하나, 그리고 Content-Type헤더가 application/x-www-form-urlencoded, multipart/form-data, text/plain 중 하나인 경우 preflight를 날리지 않고 바로 본 요청을 하는 것
- credential request : Authorization에 토큰 값을 넣어서 보내거나 세션쿠키 같은 걸 같이 날리려는 요청, 본 요청 전에 해당 요청이 받아들여지는 지 확인하기 위해 preflight를 보냄
- AccessToken을 요청에 추가해서 보내기에 Credential Request이고 그렇기에 api에 withCredentials:true 로 설정해야 하고
- 서버에서도 Preflight를 위해 OPTIONS메소드도 허용해야 하며, setAllowCredential을 true로 설정하고, allowOrigin에 클라이언트의 주소를 추가해야 한다
전송한 헤더가 보이지 않는다..
- RefreshToken을 쿠키와 헤더 둘 다 추가해서 보내고 싶었는데, 쿠키는 잘 전송되었는데 헤더가 확인이 되지 않았다.
- 내가 설정한 헤더를 노출시키기 위해선 Access-Control-Expose-Headers에 해당 헤더를 추가해야 한다. 따라서, addExposedHeader로 헤더를 추가하여 해결
보안 설정도 하고 정말 하라는 거 다 했는데,, 왜 인증이 안되는거지 ??
- credential설정, cors설정, exposed헤더 설정 , JwtFilter 설정 등등 다 되었다고 생각했는데, 뜬금없이 인증이 필요한 api를 요청해보니 401이떠버렸다..
- 잘 생각해보니, axios요청 시 파라미터가 {url,data,option}인데 난 (url, header옵션, credential설정) 이렇게 사용하고 있었다.
- data가 있는 경우 2번째 인자로 데이터를 넣고, 없는 경우 header와 credential설정 부분을 합쳐주니 해결 됨
- ..프론트 지식이 참 부족하다
쿠키 보안 설정
- 내가 알기론, 쿠키는 요청 마다 자동으로 넘어가는 것으로 알고 있었다.
- 쿠키는 크게 퍼스트 파티 쿠키와 서드 파티 쿠키로 나뉜다.
- 퍼스트 파티 쿠키 : 클라이언트 도메인과 서버 도메인이 같은 경우
- 서드 파티 쿠키 : 클라이언트 도메인과 서버 도메인이 다른 경우
- 옛날엔 진짜 그랬다고 하는데, 요즘 크롬은 CSRF공격 때문에 SameSite쿠키 정책을 운영
- 서드 파티 쿠키의 취약점 때문에 도입된 기술
- None (기존 쿠키 전달방식과 같음)
- 근데 None으로 설정하면 쿠키가 secure설정이 되어있어야 함
- Strict(크로스 사이트 요청 모두 거부)
- Lax(특정 서드파티 쿠키만 전송)
- a태그, location.replace, 302 redirect에서 전송 됨
- 현재 테스트 환경은 localhost:3000과 localhost:8080이다. 그리고, http로 통신한다.
- 따라서, Https를사용하지 않아 secure설정을 하지 못한다
- HttpOnly설정으로 XSS공격을 방지함
- 서버를 통한 로그인 요청으론 httpOnly 쿠키를 설정해서 저장시킬 수 있는데
- react에서 만든 쿠키는 httpOnly설정을 하면 서버로 전송을하지 못함
- 지금 프로젝트는 소셜로그인을 하게 되면 서버에서 토큰을 쿼리파리미터로 들고 클라이언트의 주소로 리다이렉트하기에 서버쪽에서 쿠키를 저장할 수 없다.
- 다음에는 rest api로 소셜로그인을 구현하는 것이 좋을 듯 싶다.
잘못 생각하고 있었던 것
서버에서 라우팅 되지 않은 주소를 접근하면 오류페이지가 나온다. 그리고, 리엑트 프로젝트를 빌드해서 스프링에 끼워 넣었을 때 (CSR) uri가 바뀌긴 하지만 실제로 해당 uri가 서버에서 라우팅되어 있지 않으면 원하는 페이지가 나오지 않는다.
근데, 이건 리액트를 빌드해서 서버에 끼워 넣었을 때 서버의 origin에서 react의 라우팅 주소를 이용하니 발생하는 문제였다.
react origin으로 (ex, localhost:3000) react에서 라우팅 되어 있는 경로는 물리 경로로 이동시켜도 실제 리액트 페이지가 노출이 된다 !
참고자료
https://velog.io/@sophia5460/Spring-Boot-OAuth-2.0-JWT
https://hammerstudy.tistory.com/12?category=819014
https://velog.io/@sun1203/Spring-Boot-Security-Jwt-Token-Refresh-Token
https://medium.com/@uk960214/refresh-token-도입기-f12-dd79de9fb0f0
'Spring' 카테고리의 다른 글
[Spring Boot] SMTP를 이용해 이메일 인증을 구현해보자 (0) | 2023.04.17 |
---|---|
Spring Batch (0) | 2023.04.05 |
ExceptionHandling (0) | 2023.04.05 |
Spring Security - Cors. setAllowCredentials (0) | 2023.04.05 |
What is JWT??(1) (0) | 2023.04.05 |