ShareBank은 Spring Security를 이용하여 JWT 방식으로 인증 / 인가를 구현하였다. 구현 과정을 정리하고자 글을 작성하였기 때문에 JWT 에 대한 개념은 생략한다.
@RequiredArgsConstructor
@Configuration
@Slf4j
public class SecurityConfig {
private final UserRepository userRepository;
private final JwtService jwtService;
private final ObjectMapper mapper;
private final RedisTemplate redisTemplate;
@Value("${uri.permits}")
private final List<String> permitUrl;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
.csrf().disable()
.cors().and()
.formLogin().disable()
.and()
.httpBasic().disable()
.authorizeRequests()
.antMatchers(permitUrl.toArray(String[]::new)).permitAll();
http.addFilterAfter(new LogoutFilter(userRepository, jwtService), AuthorizationFilter.class);
http.addFilterBefore(new JwtAuthorizationFilter(jwtService, userRepository, permitUrl), AuthorizationFilter.class);
http.addFilterBefore(new LoginFilter(mapper, userRepository, jwtService, redisTemplate), UsernamePasswordAuthenticationFilter.class);
http.logout().disable();
return http.build();
}
}
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
JWT 토큰 기반의 인증을 사용하므로 세션 관리 정책을 "STATELESS"로 설정하여 세션을 사용하지 않는다.
.csrf().disable()
CSRF(Cross-Site Request Forgery) 공격 방어를 비활성화 한다.
📢 비활성화 하는 이유는 JWT 토큰을 사용하면 토큰 자체가 요청의 무결성을 검증하므로 굳이 CSRF 토큰을 포함할 필요가 없다.
.cors().and()
CORS(Cross-Origin Resource Sharing) 설정을 활성화 한다.
(CORS 관련 설정을 추가해주어야 한다.)
.formLogin().disable()
우리는 Json 으로 ID, PW 를 받아 로그인을 할 것 이므로 폼 기반 로그인을 비활성화 한다.
.httpBasic().disable()
JWT 방식을 사용하므로 HTTP 기본 인증을 비활성화 한다.
.antMatchers(permitUrl.toArray(String[]::new)).permitAll()
permitUrl에 정의된 URL 패턴을 허용한다.
.addFilterAfter(new LogoutFilter(userRepository, jwtService), AuthorizationFilter.class)
로그아웃 필터를 AuthorizationFilter 뒤에 추가한다.
.addFilterBefore(new JwtAuthorizationFilter(jwtService, userRepository, permitUrl), AuthorizationFilter.class)
JWT인증 필터를 AuthorizationFilter 앞에 추가한다.
.addFilterBefore(new LoginFilter(mapper, userRepository, jwtService, redisTemplate), UsernamePasswordAuthenticationFilter.class)
Login필터를 UsernamePasswordAuthenticationFilter 앞에 추가한다.
.logout().disable()
로그아웃 설정을 비활성화한다.
@Transactional
@Slf4j
@RequiredArgsConstructor
public class JwtAuthorizationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserRepository userRepository;
private final AntPathMatcher pathMatcher = new AntPathMatcher();
private final List<String> permitUrl;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
if (isPermitURI(request.getRequestURI())) {
chain.doFilter(request, response);
return;
}
String refreshToken = jwtService.extractRefreshToken(request)
.filter(jwtService::isTokenValid)
.orElse(null);
if (refreshToken != null) {
try {
jwtService.checkRefreshToken(response, refreshToken, extractId(jwtService.extractAccessToken(request).get()));
} catch (Exception e) {
throw new RuntimeException(e);
}
return;
}
String accessToken = jwtService.extractAccessToken(request)
.filter(jwtService::isTokenValid)
.orElse(null);
if (accessToken == null) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
String id = extractId(accessToken);
User user = userRepository.findById(id).orElseThrow(IllegalArgumentException::new);
UserDetails userDetails = org.springframework.security.core.userdetails.User.builder()
.username(user.getId())
.roles("ROLE")
.password(user.getPassword())
.build();
Authentication authentication
= new UsernamePasswordAuthenticationToken(userDetails, null);
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(request, response);
}
public String extractId(String accessToken) {
return (JWT.require(jwtService.algorithm()).build()).verify(accessToken).getClaim("id").asString();
}
public boolean isPermitURI(String uri) {
for (String permitUrl : permitUrl) {
if (pathMatcher.match(permitUrl, uri)) {
return true;
}
}
return false;
}
}
JwtAuthorizationFilter extends OncePerRequestFilter
Http 요청에 대해 단 한 번만 실행되도록 보장한다.
doFilterInternal 메서드
1. 요청 URI가 permitUrl 목록에 있는지 확인하여 승인된 URL인 경우에는 필터를 건너뛴다.
2. 현재 요청에서 refresh 토큰을 추출하고 해당 토큰이 유효한지 확인한다.
3. refresh 토큰이 있다면 jwtService.checkRefreshToken
를 호출하여 access 토큰을 다시 발급하고, 사용자에 대한 정보를 갱신한다.
4. access 토큰을 추출하고 해당 토큰이 유효한지 확인한다. 없거나 유효하지 않을때 401 응답 코드를 반환한다.
5. 사용자 정보를 사용하여 Spring Security의 UserDetails 객체를 만들고, 이를 이용하여 사용자를 인증한다.
6. SecurityContextHolder를 사용하여 현재 사용자의 인증 정보를 설정하고, 이후의 요청 처리를 계속한다.
3번 과정 : access 토큰이 만료돼서 401 을 응답했을때만 클라이언트에서 refresh 토큰을 헤더에 실어 요청하도록 설계 하였다.
그렇기 때문에 refresh 토큰이 왔다는건 access 토큰이 만료 되었다는 것이다.
@Builder
@RequiredArgsConstructor
@Slf4j
public class LoginFilter extends OncePerRequestFilter {
private static final String CONTENT_TYPE = "application/json";
private final ObjectMapper objectMapper;
private final UserRepository userRepository;
private final JwtService jwtService;
private final RedisTemplate redisTemplate;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if (!request.getRequestURI().equals("/login")) {
filterChain.doFilter(request, response);
return;
}
if (request.getContentType() == null || !request.getContentType().equals(CONTENT_TYPE)) {
throw new AuthenticationServiceException("Authentication Content-Type not supported: " + request.getContentType());
}
String messageBody = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);
Map<String, String> usernamePasswordMap = objectMapper.readValue(messageBody, Map.class);
String id = usernamePasswordMap.get("id");
String password = usernamePasswordMap.get("password");
User user = userRepository.findById(id).orElseThrow(IllegalArgumentException::new);
String dbPassword = user.getPassword();
if (PasswordEncoderFactories.createDelegatingPasswordEncoder().matches(password, dbPassword)) {
String newAccessToken = jwtService.createAccessToken(id);
String newRefreshToken = jwtService.createRefreshToken();
jwtService.sendBothToken(response, newAccessToken, newRefreshToken);
redisTemplate.opsForValue().set(id, newRefreshToken,14,TimeUnit.DAYS);
HttpServletRequest requestWrapper = new CustomHttpServletRequestWrapper(request, messageBody);
filterChain.doFilter(requestWrapper, response);
}
}
}
doFilterInternal 메서드
1. 로그인 요청 URI("/login")인지 확인하고, 그렇지 않으면 필터 체인을 계속 진행한다.
2. 요청의 본문 데이터를 JSON 으로 변환하여 맵 형태로 읽어들이고, 사용자의 ID와 암호를 추출한다.
3. 데이터베이스에서 해당 ID를 사용하여 사용자 정보를 검색한다.
4. 암호를 데이터베이스에 저장된 암호와 비교하여 암호가 일치하면, JWT 토큰 생성을 통해 새로운 액세스 토큰과 리프레시 토큰을 생성하여 응답으로 반환한다.
5. 생성된 리프레시 토큰을 Redis에 저장한다.
마지막으로, 필터가 체인을 계속 진행하도록 filterChain.doFilter(requestWrapper, response)를 호출한다.
이 때 requestWrapper는 원래의 요청을 감싸고 있으며, 이를 통해 다음 단계에서도 요청 데이터를 사용할 수 있다.
📢 관련 설명 : https://velog.io/@ch0jm/yynd2hg2
로그인 필터를 만들어서 모든 요청이 로그인 필터를 거치게 할 필요가 있을까..? 필터가 보안에 강하기 때문..? 로그인은 한 url로만 요청을 하는데 controller에서 처리하면 안되나? 전역적으로 처리할 필요가 있는것도 아닌데 보안이랑 관련이 있나?
@Service
@RequiredArgsConstructor
@Slf4j
public class JwtService {
@Value("${jwt.access.header}")
private String ACCESS_HEADER;
@Value("${jwt.refresh.header}")
private String REFRESH_HEADER;
@Value("${jwt.access.expiration}")
private int ACCESS_EXPIRATION_TIME;
@Value("${jwt.refresh.expiration}")
private int REFRESH_EXPIRATION_TIME;
@Value("${jwt.secretKey}")
private String secretKey;
private static final String PREFIX = "Bearer ";
private static final String ACCESS_TOKEN_SUBJECT = "AccessToken";
private static final String REFRESH_TOKEN_SUBJECT = "RefreshToken";
private static final String EXPIRED_TOKEN_SUBJECT = "ExpiredToken";
private final UserRepository userRepository;
private final RedisTemplate redisTemplate;
public String createAccessToken(String id) {
return JWT.create()
.withSubject(ACCESS_TOKEN_SUBJECT)
.withExpiresAt(new Date(System.currentTimeMillis() + ACCESS_EXPIRATION_TIME))
.withClaim("id", id)
.sign(algorithm());
}
public String createRefreshToken() {
return JWT.create()
.withSubject(REFRESH_TOKEN_SUBJECT)
.withExpiresAt(new Date(System.currentTimeMillis() + REFRESH_EXPIRATION_TIME))
.sign(algorithm());
}
public String createExpiredToken() {
return JWT.create()
.withSubject(EXPIRED_TOKEN_SUBJECT)
.withExpiresAt(new Date(System.currentTimeMillis()))
.sign(algorithm());
}
public Algorithm algorithm() {
return Algorithm.HMAC512(secretKey);
}
public Optional<String> extractAccessToken(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader(ACCESS_HEADER))
.filter(token -> token.startsWith(PREFIX))
.map(token -> token.replace(PREFIX, ""));
}
public Optional<String> extractRefreshToken(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader(REFRESH_HEADER))
.filter(token -> token.startsWith(PREFIX))
.map(token -> token.replace(PREFIX, ""));
}
public boolean isTokenValid(String token) {
try {
JWT.require(algorithm()).build().verify(token);
return true;
} catch (Exception e) {
return false;
}
}
public void sendBothToken(HttpServletResponse response, String accessToken, String refreshToken) {
sendAccessToken(response, accessToken);
sendRefreshToken(response, refreshToken);
}
public void sendAccessToken(HttpServletResponse response, String accessToken) {
response.addHeader(ACCESS_HEADER, PREFIX + accessToken);
}
public void sendRefreshToken(HttpServletResponse response, String refreshToken) {
response.addHeader(REFRESH_HEADER, PREFIX + refreshToken);
}
public void checkRefreshToken(HttpServletResponse response,String refreshToken,String id) throws Exception {
String newAccessToken = createAccessToken(id);
String newRefreshToken = createRefreshToken();
if(!redisTemplate.opsForValue().get(id).equals(refreshToken)){
throw new Exception();
}
redisTemplate.opsForValue().set(id, newRefreshToken,14,TimeUnit.DAYS);
sendBothToken(response, newAccessToken, newRefreshToken);
}
}
토큰 생성 메소드
createAccessToken(String id)
: 사용자 ID를 받아서 액세스 토큰을 생성한다.
createRefreshToken()
: 리프레시 토큰을 생성한다.
createExpiredToken()
: 만료된 토큰을 생성한다. (테스트 목적)
암호화 알고리즘 설정
algorithm()
메서드는 HMAC512(SHA512) 알고리즘을 사용하여 JWT 토큰을 서명하는 데 사용할 암호화 알고리즘을 설정한다.
토큰 추출 및 유효성 검사 메서드
extractAccessToken(HttpServletRequest request)
: HTTP 요청에서 액세스 토큰을 추출한다.
extractRefreshToken(HttpServletRequest request)
: HTTP 요청에서 리프레시 토큰을 추출한다.
isTokenValid(String token)
: 주어진 토큰의 유효성을 검사한다.
토큰 전송 메서드
sendBothToken(HttpServletResponse response, String accessToken, String refreshToken)
: 액세스 토큰과 리프레시 토큰을 HTTP 응답 헤더에 추가하여 클라이언트에게 반환한다.
sendAccessToken(HttpServletResponse response, String accessToken)
: 액세스 토큰을 HTTP 응답 헤더에 추가하여 클라이언트에게 반환한다.
sendRefreshToken(HttpServletResponse response, String refreshToken)
: 리프레시 토큰을 HTTP 응답 헤더에 추가하여 클라이언트에게 반환한다.
리프레시 토큰 검증 및 갱신 메서드
checkRefreshToken(HttpServletResponse response, String refreshToken, String id)
: 주어진 리프레시 토큰을 검증하고, 유효하면 새로운 액세스 토큰과 리프레시 토큰을 생성하여 클라이언트에게 반환한다.