Security와 jwt의 동작은 위와 같이 진행될 거에요. 다만 5번부터 나오는 refresh token의 경우 redis를 활용할 예정이에요.
우선 프로젝트 구조는
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String BEARER_TYPE = "Bearer";
private final JwtTokenProvider jwtTokenProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 1. Request Header 에서 JWT 토큰 추출
String token = resolveToken((HttpServletRequest) request);
// 2. validateToken 으로 토큰 유효성 검사
if (token != null && jwtTokenProvider.validateToken(token)) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
// Request Header 에서 토큰 정보 추출
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_TYPE)) {
return bearerToken.substring(7);
}
return null;
}
}
JWT 의 생명 주기 및 검증 기능이 포함될 클래스에요. 토큰 생성, 토큰 복호화 및 정보 추출, 토큰 유효성 검증의 기능을 구현할 거에요.
@Value(@Value("${JWT.SECRET}") 주의점 : 256bits 이상으로 해야해요. 만약 낮게 설정한다면 에러가 뜰 거에요.
public class JwtTokenProvider {
private static final String AUTHORITIES_KEY = "auth";
private static final String BEARER_TYPE = "Bearer";
private static final long ACCESS_TOKEN_EXPIRE_TIME = 30 * 60 * 1000L; // 30분
private static final long REFRESH_TOKEN_EXPIRE_TIME = 7 * 24 * 60 * 60 * 1000L; // 7일
private final Key key;
public JwtTokenProvider(@Value("${JWT.SECRET}") String secretKey) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
// 유저 정보를 가지고 AccessToken, RefreshToken 을 생성하는 메서드
public MemberLoginDto.TokenResDto generateToken(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())
.claim(AUTHORITIES_KEY, authorities)
.setExpiration(accessTokenExpiresIn)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
// refresh Token 생성
String refreshToken = Jwts.builder()
.setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
return MemberLoginDto.TokenResDto.builder()
.grantType(BEARER_TYPE)
.accessToken(accessToken)
.refreshTokenExpirationTime(REFRESH_TOKEN_EXPIRE_TIME)
.build();
}
// JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드
public Authentication getAuthentication(String accessToken) {
// 토큰 복호화
Claims claims = parseClaims(accessToken);
if (claims.get(AUTHORITIES_KEY) == null) {
throw new SystemException(ErrorCode.INVALID_TOKEN);
}
// 클레임에서 권한 정보 가져오기
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
// UserDetails 객체를 만들어서 Authentication 리턴
UserDetails principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
// 토큰 정보를 검증하는 메서드
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info("Invalid JWT Token", e);
} catch (ExpiredJwtException e) {
log.info("Expired JWT Token", e);
} catch (UnsupportedJwtException e) {
log.info("Unsupported JWT Token", e);
} catch (IllegalArgumentException e) {
log.info("JWT claims string is empty.", e);
}
return false;
}
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
public Long getExpiration(String accessToken) {
// accessToken 남은 유효시간
Date expiration = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody().getExpiration();
// 현재 시간
Long now = new Date().getTime();
return (expiration.getTime() - now);
}
}
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebConfig {
private final String SWAGGER = "/swagger-ui/**";
private final String[] MEMBER_PERMIT = {
...
};
private final String[] MEMBER_AUTH = {
...
};
private final String[] POST_AUTH = {
...
};
private final String[] FOLLOW_AUTH = {
...
};
private final JwtTokenProvider jwtTokenProvider;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.httpBasic().disable()
.csrf().disable()
.cors();
httpSecurity
.authorizeHttpRequests()
.antMatchers(SWAGGER).permitAll()
.antMatchers(MEMBER_PERMIT).permitAll()
.antMatchers(MEMBER_AUTH).authenticated()
.antMatchers(FOLLOW_AUTH).authenticated()
.antMatchers(POST_AUTH).authenticated();
httpSecurity
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
httpSecurity
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class)
return httpSecurity.build();
}
}
csrf(cross site request forgery) : 사이트 간 위조 요청
인증된 사용자의 토큰을 탈취해 위조된 요청을 보냈을 경우를 파악해 방지하는 위한 것이에요.
- ✅ disalbe 이유?
rest api 에서는 권한이 필요한 요청을 위해서 인증 정보를 포함시켜야 함.
서버에 인증정보를 저장하지 않기 때문에 작성할 필요 없음.
(JWT를 쿠키에 저장하지 않기 때문)
jwt를 사용할 목적이기에 세션을 사용하지 않기 때문이에요.
UsernamePasswordAuthenticationFilter에 가기 전에 직접 만든 jwtTokenProvider를 사용하기 위함이에요.
@Service
@Slf4j
@RequiredArgsConstructor
public class MemberDetailService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
log.info(">>> 회원 정보 찾기, {}", email);
return memberRepository.findByEmail(email)
.map(this::createUserDetails)
.orElseThrow(() -> new SystemException(String.format("%s %s", email, ErrorCode.USER_NOT_FOUND),
ErrorCode.USER_NOT_FOUND)
);
}
private UserDetails createUserDetails(Member member) {
return new MemberDetail(member);
}
}
@Getter
public class MemberDetail implements UserDetails {
private final Member member;
public MemberDetail(Member member) {
this.member = member;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<GrantedAuthority> authorities = new ArrayList<>();
// 역할 목록
GrantedAuthority roleAuthority = new SimpleGrantedAuthority("ROLE_USER");
authorities.add(roleAuthority);
return authorities;
}
@Override
public String getPassword() {
return this.member.getPassword();
}
@Override
public String getUsername() {
return this.member.getEmail();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
@Configuration
public class PasswordEncoderConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
@Getter @Setter
public class MemberLoginDto {
private String email;
private String password;
public UsernamePasswordAuthenticationToken toAuthenticationToken() {
return new UsernamePasswordAuthenticationToken(email, password);
}
@Getter @Builder
public static class TokenResDto {
private String grantType;
private String accessToken;
private String refreshToken;
private Long refreshTokenExpirationTime;
}
}
다음 편에는 redis를 사용한 refresh token 구현을 할거에요.
안녕하세요, 포스팅 잘 봤습니다. 작성해주신 포스팅 참고하여 SpringSecurity + JWT 구현하려고 하는데
JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드에서 작성하신 SystemException 부분은 클래스를 따로 작성하신걸까요?