Security와 jwt를 이용하여 회원 기능을 구현하는 과정을 알아보자.
JWT 관련
1) TokenProvider : 유저 정보로 JWT 토큰을 만들거나 토큰을 바탕으로 유저 정보를 가져온다.
2) JwtFilter : Spring Request 앞단에 붙일 Custom Filter
Spring Security 관련
1) JwtSecurityConfig : JwtFilter를 추가한다.
2) JwtAccessDeniedHandler : 접근 권한이 없을 때 403 에러를 발생시킨다.
3) JwtAuthenticationEntryPoint : 인증 정보가 없을 때 401 에러를 발생시킨다.
4) SecurityConfig : 스프링 시큐리티에 필요한 설정
5) SecurityUtil : SecurityContext에서 전역으로 유저 정보를 제공하는 클래스
6) CorsConfig : 서로 다른 서버 환경에서 자원을 공유할 때 필요한 설정
1. 라이브러리 설정
jwt를 사용하기 위해 build.gradle에 라이브러리를 추가해준다.
dependencies {
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}
2. JwtToken DTO를 생성
클라이언트에 보내기 위한 dto를 생성한다.
@Builder
@Data
@AllArgsConstructor
public class JwtToken {
private String grantType;
private String accessToken;
private String refreshToken;
}
grantType은 jwt에 대한 인증타입이다.
3. 암호키 설정
암호키를 생성하고, secret key를 application.yml에서 설정한다. HS256알고리즘을 사용하기 위해 32글자 이상으로 설정해준다.
4. JwtTokenProvider 구현
@Slf4j
@Component
public class JwtTokenProvider {
private String AUTHORITIES_KEY = "Authorities";
private String AUTH_TYPE = "Bearer";
private long ACCESS_TOKEN_EXPIRE_TIME = 86400000;
private long REFRESH_TOKEN_EXPIRE_TIME = 8640000 * 7;
private final Key key;
public JwtTokenProvider(@Value("${dot.jwt.secretKey}") String secretKey) {
byte[] keyBytes = secretKey.getBytes();
this.key = Keys.hmacShaKeyFor(keyBytes);
}
public JwtToken generateTokenDto(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 생성
Date refreshTokenExpiresIn = new Date(now + REFRESH_TOKEN_EXPIRE_TIME);
String refreshToken = Jwts.builder()
.setExpiration(refreshTokenExpiresIn)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
return JwtToken.builder()
.grantType(AUTH_TYPE)
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
public Authentication getAuthentication(String accessToken) {
// 토큰 복호화
Claims claims = parseClaims(accessToken);
if (claims.get(AUTHORITIES_KEY) == null) {
throw new RuntimeException("Token with no permissions information");
}
Collection<? extends GrantedAuthority> authorities = Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.toList();
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();
}
}
}
Security와 jwt 토큰을 이용하여 인증과 권한 부여를 처리하는 클래스이다. jwt 토큰 생성, 복호화, 검증 기능을 구현한다.
1) generateToken()
Authentication 객체를 기반으로 Access토큰과 Refresh 토큰을 생성한다.
2) getAuthentication()
주어진 Access 토큰을 복호화하여 사용자의 Authentication(인증 정보)를 생성한다.
Claims에서 권한 정보를 추출하고, User 객체를 생성하여 Authentication 객체로 반환한다.
3) validateToken()
주어진 토큰을 검증하여 유효성을 확인한다.
Jwts.parserBuilder를 사용하여 토큰의 서명키를 설정하고, 예외처리를 통해 유효성 여부를 판단한다.
4) parseClaims()
Claims는 토큰에서 사용할 정보의 조각이다.
주어진 access 토큰을 복호화하고, 만료된 토큰인 경우에도 Claims를 반환한다.
parseClaimsJws()가 토큰의 검증과 파싱을 모두 수행한다.
5. JwtFilter
클라이언트 요청시 jwt 인증을 하기 위한 커스텀 필터로, jwt 토큰을 처리하고, 유효한 토큰인 경우 인증 정보를 SecurityContext에 저장하여 인증된 요청을 처리할 수 있도록 한다. jwt를 통해 이름, 비밀번호 인증을 수행한다.
public class JwtFilter extends OncePerRequestFilter {
private String ACCESS_TOKEN_HEADER = "Authorization";
private String AUTH_TYPE = "Bearer";
private final JwtTokenProvider jwtTokenProvider;
public JwtFilter(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String jwt = resolveToken(request);
// 토큰 유효성 검사
if (StringUtils.hasText(jwt) && jwtTokenProvider.validateToken(jwt)) {
Authentication authentication = jwtTokenProvider.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
// request 헤더에서 토큰 정보 가져오기
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(ACCESS_TOKEN_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(AUTH_TYPE)) {
return bearerToken.split(" ")[1].trim();
}
return null;
}
}
1) doFIlterInternal
resolveToken() 메서드로 요청 헤더에서 jwt 토큰을 추출하고, JwtTokenProvider의 validateToken() 메서드로 jwt 토큰의 유효성을 검증한다. 토큰이 유효하면 JwtTokenProvider의 getAuthentication() 메서드로 인증 객체를 가져와서 SecurityContext에 저장한다. 그러면 요청을 처리하는 동안 인증정보가 유지된다. chain.doFilter()를 호출하여 다음 필터로 요청을 전달한다.
2) resolveToken()
HttpServletRequest에서 토큰 정보를 추출해주고, Authorization 헤더에서 Bearer 접두사로 시작하는 토큰을 추출하여 반환한다.
6. SecurityConfig를 설정
Spring Security 설정을 담당한다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
private static final String[] ALLOW_URL = {"/", "/member/login", "/member/signUp/**", "/member/findPw/**"};
public SecurityConfig(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.headers((headersConfig) ->
headersConfig.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable))
.authorizeHttpRequests((authorizeRequests) ->
authorizeRequests
.requestMatchers(ALLOW_URL).permitAll()
.anyRequest().authenticated())
.formLogin(AbstractHttpConfigurer::disable)
.logout(Customizer.withDefaults())
.addFilterBefore(new JwtFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
1) filterChain()
HttpSecurity를 구성하여 보안 설정을 정의한다.
2) passwordEncoder()
여러 암호화 알고리즘을 지원하며, 비밀번호를 인코딩할 때 사용한다.
7. CustomUserDetails, CustomUserDetailsService 구현
public class CustomUserDetails implements UserDetails {
private final Member member;
public CustomUserDetails(Member member) {
this.member = member;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
String roles = member.getRole().toString();
for (String role : roles.split(",")) {
authorities.add(() -> role);
}
return authorities;
}
@Override
public String getPassword() {
return member.getPassword();
}
@Override
public String getUsername() {
return 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;
}
}
@Service
@RequiredArgsConstructor
@Transactional
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
Member member = memberRepository.findMemberByEmail(email).orElseThrow(() -> new RuntimeException("no user"));
return new CustomUserDetails(member);
}
}
service에서 loadUserByUsername 메서드를 오버라이드하여 넘겨 받은 UserDetails와 Authentication 패스워드를 비교하고 검증하는 로직을 처리한다. db에 값이 존재하면 UserDetails 객체를 만들어서 리턴한다.