클라이언트의 /Login 요청시
public class UserRequestDto {
private String username;
private String password;
}
UserRequestDto를 통해 입력한 username과 password 받아온다.
이는 Mapping 되어있는 AuthContoller에서 처리
@PostMapping("/login")
public ResponseEntity<TokenDto> login(@RequestBody UserRequestDto userRequestDto) {
return ResponseEntity.ok(authService.login(userRequestDto));
}
username과 password가 들어오면 AuthService의 login() 메서드를 통해 검증 + 검증완료시 Token 발급
@Transactional
public TokenDto login(UserRequestDto userRequestDto) {
// 1. Login ID/PW 를 기반으로 AuthenticationToken 생성
UsernamePasswordAuthenticationToken authenticationToken = userRequestDto.toAuthentication();
// 2. 실제로 검증 (사용자 비밀번호 체크) 이 이루어지는 부분
// authenticate 메서드가 실행이 될 때 CustomUserDetailsService 에서 만들었던 loadUserByUsername 메서드가 실행됨
// authentication 객체에 user 정보가 담기게됨
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
// 3. 인증 정보를 기반으로 JWT 토큰 생성
TokenDto tokenDto = tokenProvider.generateTokenDto(authentication);
// 4. RefreshToken 저장
RefreshToken refreshToken = RefreshToken.builder()
.key(authentication.getName())
.value(tokenDto.getRefreshToken())
.build();
refreshTokenRepository.save(refreshToken);
// 5. 토큰 발급
return tokenDto;
}
토큰의 종류는 accessToken과 refreshToken 두가지 종류가 있음.
accessToken은 로그인된 아이디의 권한정보와 user 정보를 담고있음.
refreshToken은 로그인시 DB에 저장되는 token. 로그아웃시 삭제됨. accesstoken의 보안을 위해 사용한다고함
토큰 발급은 tokenProvider의 generateTokenDto() 메서드를 통해 이루어짐 (위 코드의 주석 3번에서 실행됨)
public TokenDto generateTokenDto(Authentication authentication) {
// authentication객체에 저장된 권한들 가져오기
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
// Access Token 생성
Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
String accessToken = Jwts.builder()
.setSubject(authentication.getName()) // payload "sub": "name"
.claim(AUTH_KEY, authorities) // payload "auth": "ROLE_USER"
.setExpiration(accessTokenExpiresIn) // payload "exp": 1516239022 (예시)
.signWith(key, SignatureAlgorithm.HS512) // header "alg": "HS512"
.compact();
// Refresh Token 생성
String refreshToken = Jwts.builder()
.setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
.signWith(key, SignatureAlgorithm.HS512)
.compact();
return TokenDto.builder()
.grantType(BEARER_TYPE)
.accessToken(accessToken)
.accessTokenExpiresIn(accessTokenExpiresIn.getTime())
.refreshToken(refreshToken)
.build();
}
발급된 토큰은 어떻게 사용하나?
login성공시 return값으로 accessToken이 주어짐. 이를 Http Header에 Bearer 형식으로 넣어주도록 코드를 짜면 어떤 아이디로 로그인 성공시 header에 토큰을 부여받게 됨.
부여받은 토큰은 jwtFilter를 통해 검증된 후 올바른 토큰이면 Security에서 설정해둔 권한에 따라 api 접근 가능.
jwtFilter class는 OncePerRequestFilter를 상속받고 doFilterInternal 메서드를 override해서 사용함.
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//1. Request Header 에서 토큰을 꺼냄
String jwt = resolveToken(request);
// 2. validateToken 으로 토큰 유효성 검사
// 정상 토큰이면 해당 토큰으로 Authentication 을 가져와서 SecurityContext 에 저장
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
Authentication authentication = tokenProvider.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
// Request Header 에서 토큰 정보를 꺼내오기
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(Authorization);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(Bearer)) {
return bearerToken.substring(7);
}
return null;
}
아래는 tokenProvider의 validation (유효성 검증) 메서드와 getAuthentication(토큰 복호화) 메서드
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | 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;
}
public Authentication getAuthentication(String accessToken) {
//토큰 복호화
//Access Token에만 유저 정보 담음
Claims claims = parseClaims(accessToken);
if(claims.get(AUTH_KEY) == null) {
throw new RuntimeException("권한 정보 없는 토큰 입니다");
}
// 클레임에서 권한 정보 가져오기
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get(AUTH_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
// UserDetails 객체를 만들어서 Authentication 리턴
UserDetails principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
즉, 어떤 권한에 대한 요청이 들어오면 jwtFilter 에서 resolveToken메서드를 통해 http header에 있는 토큰을 가져온다. 가져온 토큰을 validation 메서드를 통해 검증하고, 정상적인 토큰이라면 복호화를 통해 user 정보를 토큰으로부터 뽑아내어 UserDetails 객체에 저장한다. 이후 Authentication에 UserDetails 객체 담아서 return.
여기서 Authentication 에는 토큰으로부터 받아온 유저 정보가 담겨 있다.
다시 SecurityContext에 Authentication를 저장함으로써 토큰의 유효성검사와 토큰의 권한정보를 뽑아오는 과정이 끝이 난다.
받아온 권한정보는 어떻게 사용하는가?
SecurityConfig를 살펴보자
@Override
protected void configure(HttpSecurity http) throws Exception {
// CSRF 설정 Disable
http.csrf().disable()
// exception handling 할 때 우리가 만든 클래스를 추가
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
// h2-console 을 위한 설정을 추가
.and()
.headers()
.frameOptions()
.sameOrigin()
// 시큐리티는 기본적으로 세션을 사용
// 여기서는 세션을 사용하지 않기 때문에 세션 설정을 Stateless 로 설정
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
// 로그인, 회원가입 API 는 토큰이 없는 상태에서 요청이 들어오기 때문에 permitAll 설정
.and()
.addFilter(corsFilter)
.authorizeRequests()
.antMatchers("/api/post/**").authenticated()
.antMatchers("/api/myPage/**").authenticated()
.antMatchers("/user/logout").authenticated()
.anyRequest().permitAll()
// JwtFilter 를 addFilterBefore 로 등록했던 JwtSecurityConfig 클래스를 적용
.and()
.apply(new JwtSecurityConfig(tokenProvider));
}
위 코드중 .antMatcher로 표기되어있는 api에 접근하기 위해서는 해당 요청에 맞는 권한이 필요하다.
(여기선 로그인만 한다면 해당 api로 접근 가능). 추가 권한을 설정하기 원한다면 hasRole을 이용하자
본론으로 돌아와서 권한 요청이 들어오면 스프링은 @EnableWebSecurity 어노테이션이 있는 SecurityConfig로 접근하게 된다.
Security에서 위 jwt필터를 통해 얻은 권한을 토대로 접근 가능한 api인지 확인한 후 권한이 맞으면 접근을 허용시켜준다.
여기까지가 내가 이해한 jwt의 로그인과 권한요청의 흐름이다.
틀린부분이 있을 수 있으니 조금씩 수정해보자..