Spring Security의 세션 기능은 Spring 서버 안에서 관리되는 세션이다. 따라서 Frontend 서버를 따로 개발하는 경우 Spring Security 안의 세션 기능을 직접적으로 사용할 수 없는데, 이럴 때 사용할 수 있는 방법이 바로 Json Web Token(JWT)을 사용하는 방법이다.
JWT는 Json 기반의 인증 토큰으로, 인증에 필요한 모든 정보를 암호화한 채로 담고 있어 인증 이후에도 클라이언트와 서버가 연결을 유지할 필요가 없다는 장점이 있다. 또, Json을 암호화한 짧은 문자열로 전달되기 때문에 큰 부담 없이 사용할 수 있다. 거기에 모든 인증이 인증 토큰으로 진행되므로 여러 종류의 클라이언트 서버를 동시에 사용 가능하다.
Spring 프로젝트에서 JWT를 사용하기 위해서는 우선 프로젝트의 dependency에 JWT를 추가해야 한다. JWT는 Spring Boot에 포함된 라이브러리가 아니라 직접 dependency에 추가해 주어야 하는데, JWT를 제공하는 라이브러리의 종류가 여러가지이므로 원하는 라이브러리를 골라 사용하면 된다. 이 글에서는 Auth0에서 제공하는 JWT 라이브러리를 사용하도록 하겠다.
JWT의 설치가 끝났다면, JWT를 이용한 인증 / 인가 기능을 구현하기 전에 만들어야 할 것이 있다. DTO(Data Transfer Object)가 바로 그것인데, 말은 어려워 보이지만 결국 인증 및 인가 과정에서 사용자의 username과 password를 전달할 객체이다. 사용자 객체를 그대로 사용해도 괜찮지만, 가능하면 DTO를 따로 만들어 객체간의 역할 분리를 확실하게 해야 한다.
package themion7.spring_security_jwt.dto;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
public class LoginDto {
private String username, password;
}
DTO를 만들었다면 이제 JWT를 이용한 인증 / 인가 기능을 구현하자. 가장 먼저 만들 것은 JWT를 만들 때 사용할 각종 유틸리티를 제공하는 클래스다. 이 파일을 만들지 않고 바로 JWT 인증 / 인가 필터를 만들 수도 있지만, 그 경우 변경사항이 생겼을 때 이를 적용하기 힘들어지므로 유틸리티 클래스를 만드는 것을 추천한다.
package themion7.spring_security_jwt.security.jwt;
import java.util.Date;
import com.auth0.jwt.algorithms.Algorithm;
public class JwtUtils {
public static final Long TOKEN_LIFE_SPAN = 86400000L;
public static final String SECRET = "spring_security_jwt_random_text";
public static final String PREFIX = "Bearer ";
public static final String HEADER = "Authorization";
public static Date getExpirationDate() {
return new Date(System.currentTimeMillis() + JwtUtils.TOKEN_LIFE_SPAN);
}
public static Algorithm HMAC512() {
return Algorithm.HMAC512(JwtUtils.SECRET);
}
}
다음으로 만들 것은 JWT 인증 필터이다. 이 필터의 역할은 서버에 인증 요청이 들어온 경우에 username과 password를 이용한 Spring Security의 인증 과정을 실행하고, 인증이 완료되면 해당하는 사용자에 맞는 JWT 토큰을 응답 헤더에 저장하는 역할을 한다.
package themion7.spring_security_jwt.security.jwt;
import java.io.IOException;
import java.util.ArrayList;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import com.auth0.jwt.JWT;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import lombok.AllArgsConstructor;
import themion7.spring_security_jwt.dto.LoginDto;
import themion7.spring_security_jwt.security.UserDetailsImpl;
@AllArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private AuthenticationManager authenticationManager;
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
// login request에서 유저 정보 가져오기
LoginDto loginDto = null;
try {
loginDto = new ObjectMapper().readValue(request.getInputStream(), LoginDto.class);
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
// request에서 가져온 유저 정보를 이용해 인증 토큰 생성
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(
loginDto.getUsername(),
loginDto.getPassword(),
new ArrayList<>()
);
// 생성한 인증 토큰으로 사용자 인증
return authenticationManager.authenticate(token);
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
// 인증한 사용자의 UserDetails 생성
UserDetailsImpl userDetails = (UserDetailsImpl) authResult.getPrincipal();
// 생성한 userDetails를 이용해 JWT 생성
String token = JWT.create()
.withSubject(userDetails.getUsername())
.withExpiresAt(JwtUtils.getExpirationDate())
.sign(JwtUtils.HMAC512());
// 생성한 JWT를 PREFIX를 붙여서 응답 헤더에 추가
response.addHeader(JwtUtils.HEADER, JwtUtils.PREFIX + token);
}
}
그 다음으로는 JWT 인가 필터를 만들어야 한다. 이 필터는 요청에 들어있는 헤더에서 JWT를 추출한 뒤 해당 JWT에 맞는 사용자를 Spring Security의 인가 필터에 넘겨주는 역할을 한다.
package themion7.spring_security_jwt.security.jwt;
import java.io.IOException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import com.auth0.jwt.JWT;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import themion7.spring_security_jwt.domain.User;
import themion7.spring_security_jwt.repository.UserRepository;
import themion7.spring_security_jwt.security.UserDetailsImpl;
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
// DB에서 사용자를 가져오기 위한 Repository
private final 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 {
// 헤더에서 JWT 가져오기
String header = request.getHeader(JwtUtils.HEADER);
// 헤더에 JWT가 존재하고 JwtUtils.PREFIX로 시작한다면
// JwtAuthenticationFilter에서 생성한 JWT로 간주
if (header != null && header.startsWith(JwtUtils.PREFIX)) {
// JWT에서 인증 정보를 가져와 인증 진행
Authentication auth = getUsernamePasswordAuthentication(request);
SecurityContextHolder.getContext().setAuthentication(auth);
}
// 인가 필터에 인가 과정 위탁
chain.doFilter(request, response);
}
private Authentication getUsernamePasswordAuthentication(HttpServletRequest request) {
// 헤더에서 JWT를 가져와 인증 필터에서 붙인 PREFIX를 제거
String token = request.getHeader(JwtUtils.HEADER).replace(JwtUtils.PREFIX, "");
// 헤더에서 가져온 JWT가 null이 아니라면
if (token != null) {
// 가져온 JWT에서 username 추출
String username = JWT.require(JwtUtils.HMAC512())
.build()
.verify(token)
.getSubject();
// JWT에 username이 존재한다면
if (username != null) {
// userRepository에서 username을 찾아보고
// 해당 username이 존재한다면 username에 맞는 user를 가져오고
User user = userRepository.findByUsername(username)
// 존재하지 않는다면 Exception 발생
.orElseThrow(() -> new UsernameNotFoundException("username " + username + "not found"));
// userRepository에서 가져온 User를 UserDetail로 감싼 뒤
UserDetailsImpl userDetails = new UserDetailsImpl(user);
// 가져온 User에 맞는 인증 토큰을 만들어 반환
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(username, null, userDetails.getAuthorities());
return auth;
}
}
// JWT에 맞는 인증 토큰을 반환하지 못했다면 null 반환
return null;
}
}
JWT 필터를 모두 만들었다면, 마지막으로 Spring Security의 설정에서 해당 필터를 삽입하고 Cross-Origin 보안을 해제하면 모든 과정이 끝난다. 이 때 주의해야 할 점은, JWT 필터를 추가하는 순서가 Spring Security의 필터 체인에 영향을 미치므로 정확한 순서로 필터를 추가해야 한다는 것이다. 순서를 지키지 않고 필터를 추가하는 경우 에러가 발생할 수 있다.
@AllArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private UserRepository userRepository;
// ... 기존 코드들 ...
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// 모든 인증 및 인가가 JWT로 진행되므로
// Cross-Origin 관련 보안은 disable해도 된다
.csrf()
.disable()
.cors()
.configurationSource(corsConfigurationSource())
.and()
// 모든 인증 및 인가가 JWT로 진행되므로
// Spring Security 내부의 세션은 Stateless로 운영해야 한다
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
// JWT 인증 필터와 인가 필터를 차레로 추가
.addFilter(new JwtAuthenticationFilter(authenticationManager()))
.addFilter(new JwtAuthorizationFilter(authenticationManager(), this.userRepository))
// ... 기존 코드들 ...
;
}
// Cross-Origin 허용을 위한 Bean
@Bean
public CorsConfigurationSource corsConfigurationSource() {
// Cross-Origin 설정을 관리하는 객체
CorsConfiguration config = new CorsConfiguration();
// 외부에서의 접근을 모두 허용
config.addAllowedOrigin("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
// 외부에서 접근 시 JWT를 포함한 헤더 부분을 모든 응답에 개방
config.addExposedHeader(JwtUtils.HEADER);
// 변경한 Cross-Origin 설정을 저장
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
// ... 기존 코드들 ...
}
이후 Postman을 이용해 확인해 보면, 서버의 /login으로 올바른 인증 정보를 보냈을 때 응답의 헤더에 JWT가 존재하는 것을 확인할 수 있다. 해당 JWT를 정해진 방법(이 글에서는 Bearer 방식)으로 전송할 경우, 인가 과정이 필요한 경로에 정상적으로 접근할 수 있게 된다.
'웹 > Spring' 카테고리의 다른 글
Spring에서 웹소켓 사용하기 (0) | 2022.05.17 |
---|---|
Spring Security를 이용한 세션 확인과 커스텀 인가 (0) | 2022.04.25 |
Spring Security에서 사용자 인가하기 (0) | 2022.04.12 |
Spring Security에서 DB에 존재하는 사용자 인증하기 (0) | 2022.04.12 |
Spring 프로젝트에서 Spring Security 사용하기 (0) | 2022.04.12 |