[Spring-Security] Jwt 설정
in Spring on Spring
- Jwt도입 배경
- gradle
- [config] CorsConfig
- [config] SecurityConfig
- JwtAuthenticationFilter
- JwtAuthorizationFilter
- UserDetails를 상속 받는 PrincipalDetails를 만든다
- UserDetailsService를 상속 받는 PrincipalDetailsService를 만든다.
Jwt도입 배경
최근 프론트엔드와 백엔드 서버를 분리하면서, 서버에 저장된 JSessionId를 통해 개인 정보를 불러오는 것이 어려워졌다.
JWT는 클라이언트와 서버 간의 인증 및 정보 교환을 위한 토큰 기반의 인증 방식으로, 서버에 세션을 저장할 필요 없이 클라이언트 측에서 토큰을 저장하고 이를 통해 인증을 수행할 수 있다.
이를 해결하기 위한 방법 중 하나로 JSON Web Token(JWT)을 활용한 인증 방식이 있다. 이번에 JWT를 활용하게 되면서 그 내용을 기록해 본다.
gradle
본 프로젝트는 스프링부트와 gradle을 사용한다.
dependencies {
...
implementation 'org.springframework.boot:spring-boot-starter-security'
...
testImplementation 'org.springframework.security:spring-security-test'
...
implementation 'com.nimbusds:nimbus-jose-jwt:9.31'
}
[config] CorsConfig
필터 적용을 위한 config 설정을 해준다.
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.addAllowedOrigin("*"); // e.g. http://domain1.com
config.addAllowedHeader("*");
config.addAllowedMethod("*");
source.registerCorsConfiguration("/api/**", config);
return new CorsFilter(source);
}
}
[config] SecurityConfig
필터를 적용해 준다.
@Configuration
@EnableWebSecurity // 시큐리티 활성화 -> 기본 스프링 필터체인에 등록
public class SecurityConfig {
@Autowired
private UserRepository userRepository;
@Autowired
private CorsConfig corsConfig;
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션을 사용하지 않겠다.
.and()
.formLogin().disable() // form태그로 통신하는 방식을 사용하지 않겠다.
.httpBasic().disable()
.apply(new MyCustomDsl()) // 커스텀 필터 등록
.and()
.authorizeRequests(authroize -> authroize.antMatchers("/api/v1/user/**")
.access("hasRole('ROLE_USER') or hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')")
.antMatchers("/api/v1/manager/**")
.access("hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')")
.antMatchers("/api/v1/admin/**")
.access("hasRole('ROLE_ADMIN')")
.anyRequest().permitAll())
.build();
}
public class MyCustomDsl extends AbstractHttpConfigurer<MyCustomDsl, HttpSecurity> {
@Override
public void configure(HttpSecurity http) throws Exception {
AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
http
.addFilter(corsConfig.corsFilter())
.addFilter(new JwtAuthenticationFilter(authenticationManager))
.addFilter(new JwtAuthorizationFilter(authenticationManager, userRepository));
}
}
}
JwtAuthenticationFilter
인증 관련 필터를 만든다.
기본적으로 UsernamePasswordAuthenticationFilter는 로그인 인증 시 자동으로 작동하지만, RESTful API를 사용하기 위해 .formLogin().disable() 속성을 설정하여 해당 필터가 작동하지 않도록 설정했다.
따라서, addFilter로 걸어줘야 한다.
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter{
private final AuthenticationManager authenticationManager;
// Authentication 객체 만들어서 리턴 => 의존 : AuthenticationManager
// 인증 요청시에 실행되는 함수 => /login
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
System.out.println("JwtAuthenticationFilter : 진입");
// request에 있는 username과 password를 파싱해서 자바 Object로 받기
ObjectMapper om = new ObjectMapper();
LoginRequestDto loginRequestDto = null;
try {
loginRequestDto = om.readValue(request.getInputStream(), LoginRequestDto.class);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("JwtAuthenticationFilter : "+loginRequestDto);
// 유저네임패스워드 토큰 생성
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(
loginRequestDto.getUsername(),
loginRequestDto.getPassword());
System.out.println("JwtAuthenticationFilter : 토큰생성완료");
// authenticate() 함수가 호출 되면 인증 프로바이더가 유저 디테일 서비스의
// loadUserByUsername(토큰의 첫번째 파라메터) 를 호출하고
// UserDetails를 리턴받아서 토큰의 두번째 파라메터(credential)과
// UserDetails(DB값)의 getPassword()함수로 비교해서 동일하면
// Authentication 객체를 만들어서 필터체인으로 리턴해준다.
// Tip: 인증 프로바이더의 디폴트 서비스는 UserDetailsService 타입
// Tip: 인증 프로바이더의 디폴트 암호화 방식은 BCryptPasswordEncoder
// 결론은 인증 프로바이더에게 알려줄 필요가 없음.
Authentication authentication =
authenticationManager.authenticate(authenticationToken);
PrincipalDetails principalDetailis = (PrincipalDetails) authentication.getPrincipal();
System.out.println("Authentication : "+principalDetailis.getUser().getUsername());
return authentication;
}
// JWT Token 생성해서 response에 담아주기
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication authResult) throws IOException, ServletException {
PrincipalDetails principalDetailis = (PrincipalDetails) authResult.getPrincipal();
String jwtToken = JWT.create()
.withSubject(principalDetailis.getUsername())
.withExpiresAt(new Date(System.currentTimeMillis()+JwtProperties.EXPIRATION_TIME))
.withClaim("id", principalDetailis.getUser().getId())
.withClaim("username", principalDetailis.getUser().getUsername())
.sign(Algorithm.HMAC512(JwtProperties.SECRET));
response.addHeader(JwtProperties.HEADER_STRING, JwtProperties.TOKEN_PREFIX+jwtToken);
}
}
JwtAuthorizationFilter
인가 관련 필터를 만든다.
// 인가
public class JwtAuthorizationFilter extends BasicAuthenticationFilter{
private UserRepository userRepository;
public JwtAuthorizationFilter(AuthenticationManager authenticationManager, UserRepository userRepository) {
super(authenticationManager);
this.userRepository = userRepository;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
String header = request.getHeader(JwtProperties.HEADER_STRING);
if(header == null || !header.startsWith(JwtProperties.TOKEN_PREFIX)) {
chain.doFilter(request, response);
return;
}
System.out.println("header : "+header);
String token = request.getHeader(JwtProperties.HEADER_STRING)
.replace(JwtProperties.TOKEN_PREFIX, "");
// 토큰 검증 (이게 인증이기 때문에 AuthenticationManager도 필요 없음)
// 내가 SecurityContext에 집적접근해서 세션을 만들때 자동으로 UserDetailsService에 있는 loadByUsername이 호출됨.
String username = JWT.require(Algorithm.HMAC512(JwtProperties.SECRET)).build().verify(token)
.getClaim("username").asString();
if(username != null) {
User user = userRepository.findByUsername(username);
// 인증은 토큰 검증시 끝. 인증을 하기 위해서가 아닌 스프링 시큐리티가 수행해주는 권한 처리를 위해
// 아래와 같이 토큰을 만들어서 Authentication 객체를 강제로 만들고 그걸 세션에 저장!
PrincipalDetails principalDetails = new PrincipalDetails(user);
Authentication authentication =
new UsernamePasswordAuthenticationToken(
principalDetails, //나중에 컨트롤러에서 DI해서 쓸 때 사용하기 편함.
null, // 패스워드는 모르니까 null 처리, 어차피 지금 인증하는게 아니니까!!
principalDetails.getAuthorities());
// 강제로 시큐리티의 세션에 접근하여 값 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
}
UserDetails를 상속 받는 PrincipalDetails를 만든다
시큐리티 권한 사용을 위해 세션에 담아줘야 한다.
public class PrincipalDetails implements UserDetails{
private User user;
public PrincipalDetails(User user){
this.user = user;
}
public User getUser() {
return user;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>();
user.getRoleList().forEach(r -> {
authorities.add(()->{ return r;});
});
return authorities;
}
}
UserDetailsService를 상속 받는 PrincipalDetailsService를 만든다.
시큐리티 권한 사용을 위해 세션에 담아줘야 한다.
@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService{
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
System.out.println("PrincipalDetailsService : 진입");
User user = userRepository.findByUsername(username);
// session.setAttribute("loginUser", user);
return new PrincipalDetails(user);
}
}