JWT을 이용한 인증, 인가
JWT 토큰은 특정 회원 정보를 담은 암호화된 문자열이다.
로그인 시에 이 JWT 토큰을 발행받고, 이 토큰을 클라이언트 측 (브라우저 등)에 보관하며 서버로의 모든 요청 Header에 이 토큰을 포함시켜서 요청한다. 서버에서는 모든 요청마다 Filter에서 이 토큰을 검증하고 사용 권한을 확인하고, 접근 거절 또는 허가를 하게 된다. 단순하다.
요약하자면 아래와 같다.
- 로그인 시 JWT 토큰 발행
- 클라이언트는 모든 요청 시 Header에 발급받은 토큰을 포함시킨다.
- 서버는 모든 요청을 받기 전, Filter를 통해 해당 토큰을 검증하여 권한 여부를 확인한다.
- 권한이 있으면 접근을 허가하고, 권한이 없으면 접근을 거부한다.
JWT?
2023.11.26 - [Spring/Spring Security] - [Spring security] JWT 토큰이란?
인증과 인가
인증(Authentication)과 인가(Authorization)는 항상 함께 따라다니지만 조금 다른 용어이다. 두 단어 모두 보안과 관련 있다.
인증은 "신원을 확인하는 과정"이다. 즉, JWT 토큰이 유효한지 확인하는 과정은 "인증" 과정이다.
인가는 "접근을 허가 또는 거절하는 과정"이다. 즉, SpringSecurity Context의 Authentication 객체를 확인하여 접근을 허가할지 말지를 선택하는 과정이다.
두 가지는 비슷하지만 조금 다르다.
인증은 인가로 이어진다. 인증을 통해 신원을 확인하고, 인가의 과정(접근을 허용할지 거절할지)을 거치게 된다.
그러나 인가는 인증으로 이어지지 않는다. 그저 접근을 허용 또는 거절하는 과정일 뿐이다.
예를 들어보자.
비행기의 탑승권에는 신원 정보와 특정 비행기에 탈 수 있는 권한이 들어있다. 이 비행기를 타기로 한 사람이 맞는지 확인도 할 수 있고, 이 비행기에 타는 걸 허용할지 거절할지 선택하는 지표가 될 수도 있다. 여기서 "이 사람이 맞는지 확인"은 인증이고 "비행기에 타는 걸 허용할지 거절할지"는 인가이다.
이번엔 공연 티켓을 생각해 보자. 공연 티켓은 보통 그 공연의 입장을 허용할지 말지 선택하는 지표일 뿐, "누가"가 공연에 입장하는지는 중요하지 않다. 좌석 정보와 이 공연에 입장할 수 있는 정보만 포함한다. 즉, 이 공연 티켓을 확인하여 공연장 입장을 허용 또는 거절하는 것은 인가이다.
이제 Spring Security와 JWT 토큰을 이용해서 인증 인가 하는 과정을 알아보자.
Spring Security - JWT 인증, 인가 과정
Login 요청 시 Token 발행
- 로그인 시 입력받은 ID, PW로 등록된 User를 찾는다.
- User의 특정 정보(PK 또는 UK)로 JWT 토큰을 생성한다. (accessToken)
- 클라이언트에게 응답 값으로 JWT accessToken를 반환한다.
- 클라이언트는 이 accessToken을 보관하며 모든 요청의 HttpHeader에 포함시킨다.
(1) HTTP Request
- HttpRequest의 Header에 항상 accessToken을 포함시켜서 요청한다.
- 일반적으로 Authorization : Bearer {accessToken} 형식으로 설정한다.
(2), (3) Authentication Filter
Fitler에서 HTTP Header를 읽어서 token을 검증한다.
public class JwtAuthenticationFilter extends OncePerRequestFilter {...}
AuthenticatinoFilter에서 TokenProvider를 통해 token 검증 로직을 진행하고, token 유효성 여부에 따라 정상 진행 또는 예외를 발생시킨다.
(4) Authentication 객체 생성
- token을 parsing 하여 해당 token내에 들어있는 User 정보를 알아낸다.
- 해당 User 정보로 UserDetailService에서 loadUserByUsername을 overiding 해서 UserDetails를 상속받은 클래스를 반환받는다.
- 해당 userDetails를 매개변수로 담아서 Authentication객체를 생성한다.
public Authentication getAuthentication(String token) {
String username = this.getUsername(token);
UserDetails userDetails = authService.loadUserByUsername(username);
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
(5), (6) SecurityContext에 upload
Filter에서 위의 로직을 따라 Authentication 객체를 생성한 뒤, 이를 SecurityContext에 담는다.
SecurityContextHolder.getContext().setAuthentication(auth);
JwtAuthentication Filter doFilterInternal 메서드
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
//토큰 파싱
String token = jwtTokenService.resolveTokenFromRequest(request.getHeader(TOKEN_HEADER));
//token 값이 빈 문자열이거나, 무효한 형식의 토큰이거나, BlackList에 포함된 토큰인지 확인
if (StringUtils.hasText(token) && jwtTokenService.validateToken(token)
&& !jwtTokenService.isAccessTokenDenied(token)) {
//토큰 유효성 검증 성공시 로직
Authentication auth = jwtTokenService.getAuthentication(token); //Authentication 객체 생성
SecurityContextHolder.getContext().setAuthentication(auth); //SecurityContext에 넣음
} else {
log.info("토큰 유효성 검증 실패!!!");
}
filterChain.doFilter(request, response);
}
로그인 여부 확인
@AuthenticationPrincipal 어노테이션으로 현재 로그인 유저를 확인할 수 있다.
@GetMapping("/xxx")
public ResponseEntity<?> methodXxx(@AuthenticationPrincipal UserDetails userDetails) {
String username = userDetails.getUsername();
Member member = memberRepository.findByUsername(username);
return ResponseEntity.ok(member);
}
@PreAuthorize("hasRole('ROLE_ADMIN')") 어노테이션으로 유저의 권한에 따른 접근 허용을 설정할 수 있다.
@PreAuthorize("hasRole('ROLE_ADMIN')")
@GetMapping("/xxx")
public ResponseEntity<?> methodXxx(@AuthenticationPrincipal UserDetails userDetails) {
}
RefreshToken / 로그아웃 처리
JWT 토큰은 토큰 자체를 무효화시킬 수 없다는 단점이 있다.
그래서 다른 방법들을 이용해서 이 문제를 해결한다.
로그아웃 시에는 특정 accessToken에 대한 BlackList를 만들어서 해당 accessToken으로 더 이상 인증 할 수 없게 만들고,
토큰 탈취 문제는 accessToken의 유효 시간을 짧게 만들어서 해결한다.
하나씩 알아보자.
RefreshToken
토큰이 탈취당하면, 이 탈취당한 토큰으로의 접근을 막을 방법이 없다. 그래서 기존 accessToken의 유효기한을 짧게 만들고, refreshToken 개념을 도입하여 refreshToken으로 accessToken을 재발급할 수 있도록 한다.
accessToken의 유효기한은 30분 정도로 짧게 만들고, refreshToken은 2주 정도로 설정해 보자.
(보안의 중요성에 따라 다름)
이렇게 하면, accessToken이 탈취당해도 30분 동안만 이용할 수 있다.
탈취 당하지 않더라도, 클라이언트 측에서 refreshToken을 이용해 accessToken을 재발급받아서 사용할 수 있도록 만들어서 다시 로그인해야 하는 귀찮음을 방지할 수 있다.
상황 1 : accessToken 만료 시
refreshToken을 이용해서 서버로 token 재발급 요청을 보낸다. 그러면, accessToken을 검증할 때와 마찬가지로 refreshToken을 검증하여 유효성을 확인하고, 새롭게 accessToken을 발행한다.
상황 2 : refreshToken 탈취 시
refreshToken의 탈취시의 상황을 직접적으로 막을 순 없다. 그러나, accessToken을 재발급할 때마다 refreshToken도 마찬가지로 재발급하는 방법을 이용할 수 있다. 이렇게 하면 로그인 시 또는 accessToken 발급 시마다 refreshToken도 초기화되게 된다.
그러나, 이렇게해도 기존의 refreshToken을 무효화할 수 없다는 문제가 있다.
refreshToken 관리
위의 문제를 해결하기 위해 한 명의 유저는 하나의 refreshToken만을 가질 수 있도록 Redis와 같은 저장소에 [ Key=User, Value=refreshToken ]으로 저장해 둔다. 이렇게 하면, refreshToken 사용 시마다, refreshToken에 담긴 유저 정보를 이용해서 서버 측 저장소(Redis)에 저장된 K-V 쌍을 이용해 두 refreshToken이 일치하는지 확인하는 과정을 통해 보안 문제를 해결할 수 있다.
- refreshToken으로 token 재발급 요청
- refreshToken parsing -> username=xxx
- Redis 저장소에서 key=xxx인 refreshToken 조회
- 요청에 포함된 refreshToken과 Redis에 저장된 refreshToken 비교
- 일치 시 재발급, Redis 저장소의 RefreshToken도 재설정
- 불 일치 시 잘못된 요청으로 재발급 거절 -> 다시 ID, PW를 이용하여 다시 로그인하도록 함
로그아웃
로그아웃도 Redis를 이용하여 해결한다.
기존 accessToken을 무효화 할 수 없기 때문에, 사용자가 로그아웃을 원할 때 Redis에 해당 accessToken을 추가한다.
이를 BlackList라고 말할 수 있다. Redis에 저장된 accessToken은 로그아웃 처리되었기 때문에, 해당 token으로는 인증을 할 수 없도록 하는 것이다.
이 검증을 위해 AuthenticationFilter에 해당 accessToken이 BlackList에 저장되어 있는 토큰인지 확인하는 로직을 추가한다.
또한, Redis에 계속 accessToken들이 쌓이게 되기 때문에, Redis 설정으로 accessToken의 만료 시간과 동일하게 Redis에 넣을 데이터의 만료 시간을 설정한다.
'JAVA & Spring > Spring Security' 카테고리의 다른 글
[Spring security] JWT 토큰이란? (0) | 2023.11.26 |
---|---|
[Spring Security] 스프링 시큐리티 SecurityContext에 직접 Authentication(PrincipalDetails) 넣기 (0) | 2023.10.16 |
[Spring] 카카오 로그인 API 사용 방법 (17) | 2023.10.15 |