본문 바로가기

Java & Spring

spring + JWT + Angular 프로젝트 (2) - Security 설정

이번 포스팅에서는 스프링 시큐리티 기본적인 설정과 JWT를 연동하는 과정에 대해 정리한다.



1. pom.xml 설정하기


Spring Security와 Jwt를 이용하기 위한 라이브러리 추가를 위해 pom.xml에 아래와 같이 추가한다. jjwt 라이브러리는 jwt 토큰을 생성하고 검증하는 기능등을 제공한다.


<!-- security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-data</artifactId>
</dependency>
<!-- jwt -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>${jjwt.version}</version>
</dependency>
<!-- security end -->


2. 자바 config 설정하기


스프링 시큐리티 설정을 위하여 WebSecurityConfigurerAdapter 클래스를 상속하고 @EnableWebSecurity 어노테이션을 설정한다. 이를 통하여 웹 기반의 보안을 제공할 수 있다.


@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
}


2-1. 사용자 인증을 위한 AuthenticationManagerBuilder 설정하기


사용자 인증을 DB에서 조회하여 커스텀하게 설정하기 위하여 UserDetailService를 등록하였다. 이에 대해서는 아래에 다시 설명하겠다. 비밀번호는 BCrypt 암호화 하여 저장하기 위해 PasswordEncoder를 등록하는 것을 볼 수 있다.



@Autowired
private UserDetailsService userDetailsService;

@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService)
.passwordEncoder(passwordEncoder());
}

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}


2-2 HttpSecurity 설정하기


HttpSecurity 객체의 기본 설정은 다음과 같다.

- 애플리케이션에 대한 모든 요청이 사용자의 인증을 요구한다.

- 사용자가 Form 기반의 로그인을 사용하여 인증을 할 수 있다.

- 사용자가 HTTP Basic 인증을 사용하여 인증을 할 수 있다.


이 프로젝에서는 JWT 토큰 기반의 설정 및 상세 설정을 위하여 아래와 같이 커스텀하게 설정한다.


1. CorsFilter와 JwtFilter를 인증전 통과하도록 등록한다.

2. 인증에 실패시 401 UNAUTHORIZED에러를 반환하기 위한 이를 구현한 클래스를 등록한다.

3. csrf 보호를 무시하기 위한 설정을 한다.

4. Stateless한 세션 관리를 위한 설정을 한다.

5. 인증(/api/authenticate) API를 제외하고 모든 API는 인증 로직을 통과하도록 설정하였다.


@Bean
public Http401ErrorEntryPoint http401ErrorEntryPoint() {
return new Http401ErrorEntryPoint();
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http
.addFilterBefore(new SimpleCorsFilter(), ChannelProcessingFilter.class)
.addFilterBefore(new JwtFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class)
.exceptionHandling()
.authenticationEntryPoint(http401ErrorEntryPoint())
.and()
.csrf()
.disable()
.headers()
.frameOptions()
.disable()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/api/authenticate").permitAll()
.antMatchers("/api/**").authenticated();
}


2-3 WebSecurity ignoring 적용하기


스프링 시큐리티에서는 정적 컨텐츠에 대해 인증을 무시하도록 설정 할 수 있다. 예제에서는 클라이언트 js, html 파일 등을 무시하도록 설정하였다.


@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring()
.antMatchers(HttpMethod.OPTIONS, "/**")
.antMatchers("/app/**/*.{js,html}")
.antMatchers("/content/**")
.antMatchers("/swagger-ui..html")
.antMatchers("/h2-console/**");
}



3 Security 관련 Filter 추가하기


3-1 CORS Filter 적용하기


HttpSecurity 객체에서 설정한 CORS 필터에 상세 구현이다. 이 필터의 목적은 크로스 도메인 이슈를 해결하기 위한 것으로 보통 SPA 개발시에는 프론트엔드와 서버를 별도로 실행하여 개발한다. 이 때 프론트에서 서버 API를 정상적으로 호출하려면 CorsFilter를 구현해 주어야 한다. 그리고 마지막에 pre-flight 요청 대응을 위한 OPTIONS 메소드로 요청시 항상 200(OK) 응답을 반환하는 것을 확인 할 수 있다.


@Component
public class SimpleCorsFilter implements javax.servlet.Filter {

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletResponse res = (HttpServletResponse) response;
HttpServletRequest req = (HttpServletRequest) request;

res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, PUT, OPTIONS");
res.setHeader("Access-Control-Expose-Headers", "Authorization, X-Total-Count, Link");
res.setHeader("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept," +
" Accept-Encoding, Accept-Language, Host, Referer, Connection, User-Agent, authorization, sw-useragent, sw-version");


if ( req.getMethod().equals("OPTIONS") ) {
res.setStatus(HttpServletResponse.SC_OK);
return;
}
chain.doFilter(request, response);
}



3-2 JWT Filter 적용하기


사용자가 인증이 필요한 API를 호출 시 위에서 등록한 JwtFilter를 통과해야 한다. 아래에서는 사용자가 API 호출시 Header에 등록한 토큰을 읽어 유효성을 검증하는 부분이다. 유효성 검사를 통과할 시 SecurityContextHolder에 인증정보를 등록하는 것을 확인 할 수 있다. 


@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
String jwt = resolveToken(httpServletRequest);
if (StringUtils.hasText(jwt) && this.jwtUtil.validateToken(jwt)) {
Authentication authentication = this.jwtUtil.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(servletRequest, servletResponse);
}

private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7, bearerToken.length());
}
return null;
}



4. UserDetailsService 구현하기


UserDetailsService는 사용자 이름(id,name,login)을 기준으로 저장소(db, ldap등)에서 사용자 정보를 조회하여 UserDetails 객체로 반환하는 기능을 하는 인터페이스다. 

아래 예제에서는 UserRepository에서 사용자를 조회하여 User 객체를 생성하여 반환하고 있다. UserDetails 인터페이스에 대해 조금 더 살펴보면 username, password, authorities 정보를 저장하고 조회 할 수 있는데 만약 이메일 혹은 그 외의 추가 정보를 저장하고 싶다면 이를 확장한 커스텀 클래스를 만들어서 저장하면 된다. 


@Component
public class DatabaseUserDetailsService implements UserDetailsService {

@Autowired
private UserRepository userRepository;

@Override
@Transactional
public UserDetails loadUserByUsername(final String login) {
String lowercaseLogin = login.toLowerCase();
Optional<User> userFromDatabase = userRepository.findOneWithAuthoritiesByLogin(lowercaseLogin);
return userFromDatabase.map(user -> {
List<GrantedAuthority> grantedAuthorities = user.getAuthorities().stream()
.map(authority -> new SimpleGrantedAuthority(authority.getName()))
.collect(Collectors.toList());
return new org.springframework.security.core.userdetails.User(lowercaseLogin, user.getPassword(), grantedAuthorities);
}).orElseThrow(() -> new UsernameNotFoundException("유저 " + lowercaseLogin + "DB에 존재하지 않습니다."));
}
}



다음 포스팅에서는 Jwt 토큰 생성과 검증을 하는 것과 인회원가입, 로그인 API 구현에 대해 정리할 것이다.


위와 관련된 모든 소스는 아래의 github에서 확인 할 수 있다.

-> https://github.com/keumtae-kim/spring-angular-app