스프링 시큐리티에 대해서 공부를 하면서, Authentication Provider, Manager?? UserDetail? Filter Chain등 여러가지 생소한 개념들을 처음 듣게 되면서 스프링 시큐리티에 잔뜩 쫄아있었다. 사실 아직도 좀 쫄아있긴 하다.
그래서 나는 정면돌파 해보기로 하였다. 유저가 로그인을 하였을 때 어떤 Flow로 Spring Security가 동작하는지를 직접 하나하나 코드들을 뜯어보면서, 공부를 해보았고, 이를 통해서 나름대로의 공포를 극복한 것 같아서 나 처럼 스프링 시큐리티에 막연하게 겁을 먹은 사람들을 위해 이렇게 글로 남기고자 한다.
세상 모든 겁쟁이들 파이팅!!
JwtLoginFilter
로 보낸다.@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class AdbancedSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private SpUserService userService;
@Bean
PasswordEncoder passwordEncoder(){
return NoOpPasswordEncoder.getInstance();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// 로그인을 처리해주는 jwtLoginFilter와 로그인 된 토큰을 매번 request마다 검증해줄 jwtCheckFilter
// jwtLoginFilter은 authenticationManager에게 유저 검증을 위임하지만, jwtCheckFilter는 사용자를 직접 가져와야 할 상황이 생기기에 SpUserService가 필요함
JWTLoginFilter jwtLoginFilter = new JWTLoginFilter(authenticationManager(), userService);
JWTCheckFilter jwtCheckFilter = new JWTCheckFilter(authenticationManager(), userService);
http
.csrf().disable()
// Token을 사용하기 때문에 세션을 사용안한다.
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 기존 UsernamePasswordAuthenticationFilter의 역할을 jwtLoginFilter가 맡게 되었음.
.addFilterAt(jwtLoginFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterAt(jwtCheckFilter, BasicAuthenticationFilter.class);
}
}
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class UserLoginForm {
private String username;
private String password;
private String refreshToken;
}
JwtLoginFilter
에서는 Ruquest를 UserLoginForm(ex) DTO)으로 받아오고, 이를 기반으로 아직은 권한이 null인 Authenticatrion을 생성해준다. 그 후 AuthenticationManager에게 해당 Authentication의 유효성 검증을 부탁한다.// id, pwd를 받아 유효한 유저인지를 검증 후 유효한 사용자라면 인증 filter를 내려주는 책임
public class JWTLoginFilter extends UsernamePasswordAuthenticationFilter{
private SpUserService spUserService;
private ObjectMapper objectMapper = new ObjectMapper();
public JWTLoginFilter(AuthenticationManager authenticationManager, SpUserService spUserService) {
super(authenticationManager);
this.spUserService = spUserService;
setFilterProcessesUrl("/login");
}
// 사용자 인증을 처리한다.
@SneakyThrows
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, TokenExpiredException {
// request로부터 UserLoginForm을 읽어온다.
UserLoginForm userLogin = objectMapper.readValue(request.getInputStream(), UserLoginForm.class);
// refreshToken이 없다면, username, password를 통해서 인증 객체를 생성
if(userLogin.getRefreshToken() == null) {
// 아직은 권한이 null인 Authentication을 생성해준다.
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(
userLogin.getUsername(), userLogin.getPassword(), null
);
// AuthenticationManager에게 token의 유효성 검증을 부탁
return getAuthenticationManager().authenticate(token);
}
// refreshToken이 존재한다면
VerifyResult verify = JWTUtil.verify(userLogin.getRefreshToken());
// AuthToken과 RefreshToken을 다시 생성해준다. -> successfulAuthentication()이 자동 호출되므로
if(verify.isSuccess()){
SpUser user = (SpUser) spUserService.loadUserByUsername(verify.getUsername());
return new UsernamePasswordAuthenticationToken(
user, user.getAuthorities());
} else{
throws new TokenExpiredException("refresh token expired");
}
}
// 인증이 성공되었을 때 호출되는 메서드
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
// Principal에 들어가있는 User를 불러온다.
SpUser spUser = (SpUser) authResult.getPrincipal();
response.setHeader("auth_token", JWTUtil.makeAuthToken(spUser));
response.setHeader("refresh_token", JWTUtil.makeRefreshToken(spUser));
response.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
// Login 성공시 유저에게 Response로 로그인 된 유저의 객체를 넘겨준다.
response.getOutputStream().write(objectMapper.writeValueAsBytes(spUser));
}
}
attemptAuthentication
메서드에서 refreshToken의 유/무 여부에 따라서 분기처리 된다.AuthenticationManager
에게 유효성 검증을 요청한다.AuthenticationManager는 AuthenticationProvider들 중에서 검증할 수 있는 AuthenticationProvider에게 인증을 위임한다.
더 자세한 것은 아래의 글을 참고 바란다.
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class VerifyResult {
private boolean success;
private String username;
}
AuthenticationSuccessHandler
)에 의해 JWTLoginFilter의 successfulAuthentication
메서드가 호출이된다.public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
// 인증 성공 후 처리할 작업을 구현합니다.
// 예를 들어, 세션 관리, 로그 기록, 사용자 정보 업데이트 등을 수행할 수 있습니다.
// 인증 성공 후 리다이렉트할 URL을 설정합니다.
response.sendRedirect("/home");
}
// 인증이 성공되었을 때 호출되는 메서드
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
// Principal에 들어가있는 User를 불러온다.
SpUser spUser = (SpUser) authResult.getPrincipal();
response.setHeader("auth_token", JWTUtil.makeAuthToken(spUser));
response.setHeader("refresh_token", JWTUtil.makeRefreshToken(spUser));
response.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
// Login 성공시 유저에게 Response로 로그인 된 유저의 객체를 넘겨준다.
response.getOutputStream().write(objectMapper.writeValueAsBytes(spUser));
}
JWTUtil
은 JWT 생성, 검증, 정의를 하는 객체이다.public class JWTUtil {
private static final Algorithm algorithm = Algorithm.HMAC256("secret-key");
private static final long AUTH_TIME = 20*60; // Auth가 유지되는 시간 = 20분
private static final long REFRESH_TIME = 60*60+24*7; // REFRESH가 유지되는 시간 = 일주일
// user 객체를 기반으로 AuthToken을 생성해주는 메서드
public static String makeAuthToken(SpUser user){
return JWT.create()
.withSubject(user.getUsername())
.withClaim("exp", Instant.now().getEpochSecond()+AUTH_TIME) // withExpireAt()은 Date 객체를 써야함으로 claim에 exp로 직접적어줌
.sign(algorithm);
}
// user 객체를 기반으로 RefreshToken을 생성해주는 메서드
public static String makeRefreshToken(SpUser user){
return JWT.create()
.withSubject(user.getUsername())
.withClaim("exp", Instant.now().getEpochSecond()+REFRESH_TIME)
.sign(algorithm);
}
// Token 유효한지를 검증하는 메서드
public static VerifyResult verify(String token){
try {
// Token의 유효성을 verify(token)를 통해서 검증, 유효하다면 VerifyResult 객체에 성공값과 유효한 토큰의 값을 반환한다.
DecodedJWT verify = JWT.require(algorithm).build().verify(token);
return VerifyResult.builder().success(true)
.username(verify.getSubject())
.build();
}catch (Exception ex){
// Token이 유효하지 않았다면 VerifyResult 객체에 실패값과 해독한 토큰의 값을 반환한다.
DecodedJWT decode = JWT.decode(token);
return VerifyResult.builder().success(false)
.username(decode.getSubject())
.build();
}
}
}
// 토큰의 유효성 검증
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
// HTTP Hear에 AUTHORIZATION 헤더에 있는 bearer 토큰을 가져옴
String bearer = request.getHeader(HttpHeaders.AUTHORIZATION);
// bearer 토큰이 잘못된 경우 그 다음 filter로 그냥 흘려 보내야한다. -> 추후에 예외 처리나 리다이렉트 처리
if(bearer == null || !bearer.startsWith("Bearer ")){
chain.doFilter(request, response);
return;
}
// bearer 토큰이면 이를 검증해본다.
String token = bearer.substring("Bearer ".length());
VerifyResult result = JWTUtil.verify(token);
// 만약 인증이 된 사용자라면 request token에 있던 username으로부터 User 객체 생성
if(result.isSuccess()){
SpUser user = (SpUser) userService.loadUserByUsername(result.getUsername());
UsernamePasswordAuthenticationToken userToken = new UsernamePasswordAuthenticationToken(
user.getUsername(), null, user.getAuthorities()
);
// 그리고 SecurityContextHolder에 Authentication을 넣어준다.
SecurityContextHolder.getContext().setAuthentication(userToken);
}else {
throw new AuthenticationException("Token is not valid");
}
chain.doFilter(request, response);
}
}
SecurityContextHolder
에 넣어준다.