
Spring Security + JWT 를 이용하여 토큰 기반 로그인을 구현하려고 한다.
사용자 인증 방식으로는 크게 session 인증과 token 인증이 있다.
내가 진행하는 프로젝트의 경우, Vetordb로 Redis를 사용할 것이기 때문에 세션 기반 인증도 충분히 고려할 상황이었다.
레디스로 세션을 관리하면 MSA환경에서도 개발자가 세션을 동기화할 필요가 없기 때문이다.
하지만, 외부 저장소가 필요하고 서버에 부하가 간다는 문제는 여전히 존재한다.
따라서 외부 시스템 없이 동기화가 가능하고 서버 부하가 적은 Jwt를 선택하기로 결정했다.
단, jwt를 사용할 경우 토큰 탈취의 위험이 있기 때문에 토큰 만료 시간을 두고 refreshToken이나 blackList를 이용한 관리 작업이 필요하다.

www.music-in-my-diary 라는 Host로 이동할 것이다.www.music-in-my-diary = http://localhost:8080
- Connector : 프로토콜에 따라 특정 Connnect로 client와의 통신을 처리하는 계층
- Engine : 특정 서비스에 대한 요청 처리 파이프라인
- Host : 네트워크 연결 주소
- Context : 여러 개의 Servlet 집합, 1개의 Project라고 생각해도 무방함
- Host와 Context의 차이를 쉽게 말하자면,
Host는 서버가 외부와 통신할 때 사용하는 도메인 주소이고,Context는 그 도메인 안에서 애플리케이션이 시작되는 경로를 의미함.- 만약 Context가 /로 설정되어 있다면, Host와 Context가 같아져서 www.music-in-my-diary.com에서 애플리케이션이 바로 시작됨.
- 하지만 Context가 /app이라면, www.music-in-my-diary.com/app에서 애플리케이션이 시작되고 그 안에서
/login,/dashboard같은uri가 사용됨.- Servlet : Controller, Service 등 각 MVC 구성요소
마침내 요청이 Servlet까지 도달했다! 원래같으면 DispatcherServlet에 도달한 요청이 적절한 Controller로 매핑되고 메서드를 실행하겠지만, 인증 요청은 다르다. 인증 요청은 DispatcherServlet에 도달하기 전, 먼저 filterChain이라는 관문을 거쳐야 한다.
/login으로 요청을 보내면, whiteListUris에 속한 uri이므로 인증 없이 통과시킨다./dashboard로 요청을 보내면, 인증이 필요한 경로임을 알아채고 Jwt필터(e.g. JwtAuthenticationFilter)를 적용시켜 토큰을 검증한다.username, password)를 기반으로 UsernamePasswordAuthenticationToken 인증 객체를 생성한다.💡
더럽게 어렵고 복잡하다..🤮🤮🤮 초초초간단하게 말하자면,
로그인 정보를 필터 체인의 DelegatingFilterProxy에서 가로채고, 내부 동작을 통해서 인증 성공 시 요청이 DispatcherServlet에 전달됨으로써 로직이 실행된다고 이해해도 충분하다.
스프링 시큐리티를 수행하기 위한 환경을 설정하는 클래스이다.
Spring Security 5.7이 릴리즈 된 이후로는 각 config들을 Bean으로 등록하는 것을 권장한다고 한다.
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final AuthenticationConfiguration authenticationConfiguration;
private final JwtService jwtService;
private final MemberService memberService;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws
Exception{
return http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(((csrf)-> csrf.disable()))
.authorizeHttpRequests(auth -> auth
.requestMatchers(getWhiteListUris()).permitAll()
.anyRequest().authenticated()
)
.addFilterAt(
new LoginFilter(authenticationManager(authenticationConfiguration), jwtService, memberService),
UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource(){
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(Arrays.asList("*"));
configuration.setAllowedMethods(Arrays.asList("GET","POST","PUT","PATCH","DELETE"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws
Exception{
return authenticationConfiguration.getAuthenticationManager();
}
private String[] getWhiteListUris() {
return new String[]{"/swagger-ui/**", "/v3/**", "/login", "/swagger-ui.html", "/swagger-resources/**", "/signup"};
}
}
whiteListUris를 제외한 경로로의 접근을 제한한다.세션을 stateless 설정한다.사용자 정보를 조회하는 UserDetailsSerivce의 구현체이다.
@Service
@RequiredArgsConstructor
public class CustomMemberDetailService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
Member member = memberRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("존재하지 않는 이메일입니다 : " + email));
return member;
}
public Member findMemberByMemberId(Long memberId){
Member member = memberRepository.findById(memberId).orElseThrow(()->new EntityNotFoundException("존재하지 않는 회원아이디입니다 : "+memberId));
return member;
}
}
member 정보를 조회한다.로그인 요청을 하면 해당 필터에서 요청을 가로챈다. username, password 같은 정보를 추출해 로그인을 시도한다.
인증을 성공하면 인증 토큰을 반환하고, 실패하면 401 에러를 던진다.
@RequiredArgsConstructor
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final JwtService jwtService;
private final MemberService memberService;
/**
* Login 시도 메서드
* */
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws
AuthenticationException {
String email = (obtainUsername(request) != null) ? obtainUsername(request) : "";
String password = (obtainPassword(request) != null) ? obtainPassword(request) : "";
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(email,password,null);
return authenticationManager.authenticate(authToken);
}
/**
* Login 성공(인증 성공) 시 메서드
* authResult : 인증 성공 후 만들어지는 인증 객체
* */
@Override
protected void successfulAuthentication(
HttpServletRequest request,
HttpServletResponse response,
FilterChain chain,
Authentication authResult) throws IOException, ServletException
{
Member member = (Member) authResult.getPrincipal();
Collection<? extends GrantedAuthority> authorities = authResult.getAuthorities();
Jwt token = jwtService.createTokens(member.getId());
addJwtToCookie(response, token.getAccessToken(), "accessToken");
sendMemberLoginResponse(response, HttpStatus.OK);
}
/**
* Login 실패 시(인증 실패) 메서드
* failed : 인증 실패 후 만들어지는 인증 실패 객체
* */
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException
{
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setCharacterEncoding("utf-8");
response.getWriter().write("인증 실패 : " + failed.getMessage());
}
/**
* Login 성공/실패 시 메세지 보내는 메서드
* */
private void sendMemberLoginResponse(HttpServletResponse response, HttpStatus status) throws IOException{
Map<String, String> messageMap = new HashMap<>();
response.setContentType("application/json;charet=UTF-8");
response.setStatus(status.value());
PrintWriter out = response.getWriter();
out.print(new ObjectMapper().writeValueAsString(messageMap));
out.flush();
}
/**
* 쿠키 굽는 메서드
* */
public void addJwtToCookie(HttpServletResponse response, String jwtToken, String cookieName){
Cookie cookie = new Cookie(cookieName, jwtToken);
cookie.setHttpOnly(true);
cookie.setSecure(true);
cookie.setPath("/");
cookie.setMaxAge(60*120);
response.addCookie(cookie);
}
}
임시 토큰을 생성하고, AuthenticationManager에 전달해 인증 처리함jwt 토큰 생성 후 쿠키에 저장하고 성공 메세지 반환함401 에러와 함께 실패 메세지 반환함HttpOnly와 Secure 속성으로 브라우저 보안 강화함
UsernamePasswordAuthenticationFilter를 뜯어보지 않아 로그인 컨트롤러를 만들었었는데, 알고보니 /login 이라는 엔드포인트로 POST 요청을 보내고 있었다.
즉, 별도의 로그인 컨트롤러 없이 해당 필터에서 사용자 인증 및 로그인 시도가 모두 가능하다.