필터의 순서 정하기
Spring Security 동작 과정
요청이 들어오면 그림과 같이 AuthenticationFilter 를 거쳐 UsernamePasswordAuthenticationToken 을 만들고 이를 이용하여 그 다음 과정을 쭉쭉 거치게 됩니다. 결론적으로는 SecurityContextHolder 에 인증이 완료된 유저네임과 패스워드가 저장되게 되죠. 그렇다면 JWT 필터는 어떤가요?
JWT 필터
JWT 필터도 별반 다르지 않습니다. 요청이 들어오면 요청 헤더에 들어있는 토큰을 꺼내어 토큰 검증을 거칩니다. 유효한 토큰이라면 아래와 같이 시큐리티에 건내줄 수 있습니다.
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(user, accessToken, new ArrayList<>());
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
Java
복사
결론은요?
요청이 들어오면 JWT 필터를 거치고 시큐리티 필터를 거치도록 하였습니다. 토큰을 먼저 꺼내보고 유효하지 않은 경우 아예 접근 자체를 못하게 하기 위해서였습니다.
JWT
AccessToken 은 Authorization 헤더에, RefreshToken 은 쿠키와 레디스에 넣는 방법을 사용했습니다.
JWTAuthenticationFilter.java
이곳에서는 JWT 필터를 거치는 api 와 거치지 않는 api 를 설정할 수 있습니다.
package com.planner.travel.global.jwt;
import com.planner.travel.global.jwt.token.TokenAuthenticator;
import com.planner.travel.global.jwt.token.TokenExtractor;
import com.planner.travel.global.jwt.token.TokenValidator;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.NotNull;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@RequiredArgsConstructor
public class JWTAuthenticationFilter extends OncePerRequestFilter {
private final TokenExtractor tokenExtractor;
private final TokenValidator tokenValidator;
private final TokenAuthenticator tokenAuthenticator;
@Override
protected void doFilterInternal(HttpServletRequest request, @NotNull HttpServletResponse response,
@NotNull FilterChain filterChain) throws ServletException, IOException {
String requestURI = request.getRequestURI();
if (requestURI.equals("/api/v1/auth/signup") ||
requestURI.equals("/api/v1/auth/login") ||
requestURI.startsWith("/api/v1/auth/token") ||
requestURI.startsWith("/api/v1/docs")
) {
filterChain.doFilter(request, response);
return;
}
String accessTokenFromHeader = tokenExtractor.getAccessTokenFromHeader(request);
String accessToken = null;
if (accessTokenFromHeader != null) {
accessToken = accessTokenFromHeader;
}
tokenValidator.validateAccessToken(accessToken);
tokenAuthenticator.getAuthenticationUsingToken(accessToken);
filterChain.doFilter(request, response);
}
}
Java
복사
TokenExtractor.java
TokenValidator.java
TokenAuthenticator.java
JWTRefreshService.java
•
AccessToken 이 만료 될 경우, 쿠키에서 refreshToken 을 꺼내어 유저를 검증합니다.
•
검증된 유저의 경우 AccessToken 과 refreshToken 이 갱신됩니다.
package com.planner.travel.global.jwt;
import com.planner.travel.global.jwt.token.SubjectExtractor;
import com.planner.travel.global.jwt.token.TokenGenerator;
import com.planner.travel.global.jwt.token.TokenType;
import com.planner.travel.global.util.CookieUtil;
import com.planner.travel.global.util.RedisUtil;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Service
@Slf4j
@RequiredArgsConstructor
public class JWTRefreshService {
private final CookieUtil cookieUtil;
private final RedisUtil redisUtil;
private final SubjectExtractor subjectExtractor;
private final TokenGenerator tokenGenerator;
public void refreshAccessToken(HttpServletRequest request, HttpServletResponse response) {
Cookie cookieFromRequest = cookieUtil.getCookie(request, "refreshToken");
String refreshToken = cookieFromRequest.getValue();
String userId = subjectExtractor.getUesrIdFromToken(refreshToken).toString();
log.info("======================================================");
log.info("refreshToken: " + refreshToken);
log.info("userId: " + userId);
log.info("======================================================");
String redisValue = redisUtil.getData(userId);
if (refreshToken.equals(redisValue)) {
String newAccessToken = tokenGenerator.generateToken(TokenType.ACCESS, userId);
String newRefreshToken = tokenGenerator.generateToken(TokenType.REFRESH, userId);
response.setHeader("Authorization", newAccessToken);
cookieUtil.setCookie("refreshToken", newRefreshToken, response);
redisUtil.setData(userId, newRefreshToken);
}
}
}
Java
복사
KeyConfiguration.java
SubjectExtractor.java
TokenGenerator.java
CookieUtil.java
RedisUtil.java
JWTController.java
package com.planner.travel.global.jwt;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping(value = "/api/v1/auth/token")
@RequiredArgsConstructor
public class JWTController {
private final JWTRefreshService jwtRefreshService;
@PostMapping(value = "/refresh")
public void refreshAccessToken(HttpServletRequest request, HttpServletResponse response) {
jwtRefreshService.refreshAccessToken(request, response);
}
}
Java
복사
Security
시큐리티에서 제공하는 form 로그인을 사용하지 않고, 로그인 리퀘스트로 받은 값을 그대로 사용하려면 시큐리티를 커스텀 해야 합니다. 그래서 시큐리티의 동작과정에서 보이는 클래스들을 아래와 같이 커스텀 하였습니다.
CustomAuthenticationFilter.java
•
로그인 리퀘스트를 통해 아이디와 비밀번호를 받습니다.
•
이를 이용하여 UsernamePasswordAuthenticationToken 을 만듭니다.
package com.planner.travel.global.security;
import com.fasterxml.jackson.core.exc.StreamReadException;
import com.fasterxml.jackson.databind.DatabindException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.planner.travel.domain.user.dto.request.LoginRequest;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import java.io.IOException;
@Slf4j
public class CustomAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
public CustomAuthenticationFilter(final AuthenticationManager authenticationManager) {
super.setAuthenticationManager(authenticationManager);
}
@Override
public Authentication attemptAuthentication(final HttpServletRequest request, final HttpServletResponse response) {
final UsernamePasswordAuthenticationToken authenticationToken;
try {
final LoginRequest loginRequest = new ObjectMapper().readValue(request.getInputStream(), LoginRequest.class);
log.info("===========================================================================");
log.info("Login user's email : " + loginRequest.email());
log.info("===========================================================================");
authenticationToken = new UsernamePasswordAuthenticationToken(loginRequest.email(), loginRequest.password());
} catch (IOException e) {
throw new RuntimeException(e);
}
setDetails(request, authenticationToken);
return this.getAuthenticationManager().authenticate(authenticationToken);
}
}
Java
복사
CustomAuthenticationProvider.java
•
UsernamePasswordAuthenticationToken 으로부터 아이디와 비밀번호를 조회합니다.
•
CustomUserDetailsService 를 사용하여 아이디로 사용자를 조회하고, 존재한다면 이를 이용하여 새로운 UsernamePasswordAuthenticationToken 을 생성합니다.
package com.planner.travel.global.security;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@RequiredArgsConstructor
public class CustomAuthenticationProvider implements AuthenticationProvider {
private final CustomUserDetailsService customUserDetailsService;
private final BCryptPasswordEncoder bCryptPasswordEncoder;
@Override
public Authentication authenticate(Authentication authentication) {
final UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication;
final String email = token.getName();
final String password = (String) token.getCredentials();
final CustomUserDetails userDetails;
userDetails = (CustomUserDetails) customUserDetailsService.loadUserByUsername(email);
if (!bCryptPasswordEncoder.matches(password, userDetails.getPassword())) {
throw new BadCredentialsException("AUTH_02");
}
return new UsernamePasswordAuthenticationToken(userDetails, password, userDetails.getAuthorities());
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(UsernamePasswordAuthenticationToken.class);
}
}
Java
복사
왜 UsernamePasswordAuthenticationToken 에 userDetails 을 넣나요?
인텔리제이에서 UsernamePasswordAuthenticationToken 을 한번 찾아가보면, 아래와같이 나옵니다.
파라미터를 보면 principal 은 Object 입니다. 그래서 유저의 아이디나 이메일같이 유저의 특정값이 아닌, 유저를 넣어주었습니다. 이 principal 은 아래와 같이 인증이 완료된 유저의 인증 정보를 꺼내올 때 사용할 수 있습니다.
public Long getLoginUserIndex() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
Object principal = authentication.getPrincipal();
Long userId = ((User) principal).getId();
return userId;
}
Java
복사
유저의 어떤 정보가 필요할 지 몰라서 User 자체를 넣어준 것이죠
CustomUserDetailsService.java
•
토큰을 통해 찾아온 이메일을 이용하여 특정 유저가 존재하는지 확인합니다.
•
만약 유저가 존재한다면, 이를 CustomUserDetails 에 넣어 반환 합니다.
package com.planner.travel.global.security;
import com.planner.travel.domain.user.entity.User;
import com.planner.travel.domain.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
@RequiredArgsConstructor
@Slf4j
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) {
Optional<User> user = userRepository.findByEmail(email);
return new CustomUserDetails(user.orElseThrow());
}
}
Java
복사
CustomUserDetails.java
사용자를 저장합니다.
package com.planner.travel.global.security;
import com.planner.travel.domain.user.entity.User;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.Collections;
public record CustomUserDetails(User user) implements UserDetails {
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.singleton(new SimpleGrantedAuthority("ROLE_USER"));
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.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;
}
}
Java
복사
총 정리
•
로그인 이후 요청이 들어올 때, JWTAuthenticationFilter 를 먼저 거치게 됩니다.
◦
리퀘스트 URI 가 토큰 검증을 거쳐야 하는 URI 인 경우 accessToken 을 검증합니다.
•
accessToken 의 검증이
◦
제대로 완료된 경우, 유저에 대한 정보를 security 에 넘겨줍니다.
◦
실패한 경우 유저 인증에 실패 했으므로, 403 을 반환합니다.
•
security 는 받은 정보를 사용하여 인가 과정을 거칩니다.
◦
인가 과정에 실패한 경우 403 을 반환합니다.