개요Spring Security의 기본 인증기본 Spring Security를 적용하면 어떻게 작동할까?기본 동작 구조1. 로그인 폼 자동 생성2. 로그인 폼에서 생성된 요청 받기3. 모든 HTTP 요청은 인증 필요동작 원리DefaultLoginPageGeneratingFilterREST API 인증을 추가하려면?UsernamePasswordAuthenticationFilterUsernamePasswordAuthenticationFilter를 재활용 하면 안되나?JWT 토큰을 발급하려면?Reference
개요
Spring Security의 기본 인증
기본 Spring Security를 적용하면 어떻게 작동할까?
Spring Security는 강력한 보안 프레임워크지만, 처음 의존성만 추가했을 때도 꽤 많은 보안 기능을 자동으로 제공한다. 특별한 설정 없이도 다음과 같은 기본 인증 흐름이 적용된다.
기본 동작 구조
1. 로그인 폼 자동 생성
Spring Security를 의존성에 추가하는 순간, 애플리케이션에는 /login이라는 경로에 접근 가능한 기본 로그인 폼이 생성된다. 이 로그인 페이지는 HTML 기반의 폼으로, username과 password를 입력받는 전통적인 방식이다.
2. 로그인 폼에서 생성된 요청 받기
- 기본 경로: POST /login
- 파라미터: username, password
- 인증 성공 시: 기본 경로 /로 리디렉션
3. 모든 HTTP 요청은 인증 필요
Spring Security의 디폴트 설정은 모든 HTTP 요청을 보호한다.
인증되지 않은 사용자가 보호된 리소스에 접근하면 다음 중 하나가 발생한다:
- HTML 요청인 경우 → /login으로 리디렉션
- REST API 요청인 경우 → 401 Unauthorized 응답 반환
동작 원리
그런데 어떤 컨트롤러도 추가하지 않았는데 어떻게 /login 페이지가 생기는 걸까?
이는 Spring Security가 제공하는 자동 설정과 FilterChainProxy 덕분이다.
FilterChainProxy는 Spring Security에서 제공하는 특수한 Filter로 SecurityFilterChain을 사용하여 다양한 보안 필터가 동작하게 한다.SecurityFilterChain에는 여러 Security filter가 존재한다.이때
DefaultLoginPageGeneratingFilter가 GET /login 으로 접근했을때 기본 로그인 페이지를 생성해주고, UsernamePasswordAuthenticationFilter 가 POST /login 으로 들어오는 인증을 처리해준다.그래서 별도의 컨트롤러 정의 없이도 필터에서 /login 으로 오는 요청들의 경우에 if 문으로 인식하여 처리해주는 것이다.
DefaultLoginPageGeneratingFilter
실제
DefaultLoginPageGeneratingFilter 구현을 통해 간단하게 알아보자public class DefaultLoginPageGeneratingFilter extends GenericFilterBean { private void doFilter( HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { // ~~~ if (!this.isLoginUrlRequest(request) && !loginError && !logoutSuccess) { chain.doFilter(request, response); } else { String loginPageHtml = this.generateLoginPageHtml(request, loginError, logoutSuccess); // ~~~ response.getWriter().write(loginPageHtml); } } private boolean isLoginUrlRequest(HttpServletRequest request) { return this.matches(request, this.loginPageUrl); } }
요청이 들어오면 this.isLoginUrlRequest(request) 인 경우(페이지가 /login 인 경우에) 기본 로그인 페이지를 보여주고 있다.
REST API 인증을 추가하려면?
그렇다면 REST API로 이메일/비밀번호 인증을 하려면 어떻게 해야할까?
DefaultLoginPageGeneratingFilter 는 웹 페이지를 생성해주는 필터기에 필요가 없어 보이나 UsernamePasswordAuthenticationFilter 는 재활용할 수 있을 것 같아 보인다.UsernamePasswordAuthenticationFilter
- Form 기반 인증 요청에서
username과password를 추출하여UsernamePasswordAuthenticationToken객체를ProviderManager에 전달합니다. UsernamePasswordAuthenticationToken은Authentication인터페이스의 구현체ProviderManager는AuthenticationManager인터페이스의 구현체
ProviderManager는DaoAuthenticationProvider를 이용하여 인증을 수행한다.DaoAuthenticationProvider는AuthenticationProvider인터페이스의 구현체
DaoAuthenticationProvider는UserDetailsService를 이용해 전달받은username과 일치하는UserDetails(저장된 사용자 정보)를 조회한다.
DaoAuthenticationProvider는PasswordEncoder를 이용해 전달받은password와 3번 과정에서 조회한UserDetails의 비밀번호가 일치하는지 검증한다.
- 4번 과정에서 비밀번호 검증까지 성공하면 사용자 인증은 성공한 것이다. 이 때, 인증이 완료된
UsernamePasswordAuthenticationToken을 반환하게 되며 이 구현체의principal값은UserDetailsService에서 조회해온UserDetails로 설정된다.
- 최종적으로, 반환된
UsernamePasswordAuthenticationToken은SecurityContextHolder에 설정된다.
그렇다면
UsernamePasswordAuthenticationToken 를 만들어 ProviderManager에 전달하면 username/password basic 인증이 된다는 것을 알 수 있었다. 그럼 이 부분만 때서 컨트롤러에서 구현해주면 재활용할 수 있을 것으로 보인다.public class AuthController { private final AuthenticationManager authenticationManager; @PostMapping("/login") public ResponseEntity<LoginResponse> authenticate(@RequestBody @Valid EmailLoginRequest emailLoginRequest) { // AuthenticationManager로 인증 시도, 이는 기존의 username/password 방식으로 인증을 시도하는 것과 동일 try { Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( emailLoginRequest.email(), emailLoginRequest.password() ) ); // 인증 성공 시 SecurityContext 에 저장 SecurityContextHolder.getContext().setAuthentication(authentication); return ResponseEntity.ok(); } catch (AuthenticationException e) { throw new CustomException(ErrorCode.EMAIL_LOGIN_FAILED); } }
다음과 같이 request를 받아 새롭게
UsernamePasswordAuthenticationToken을 직접 생성해준다. 이후 이를 ProviderManager에 넘기는데 AuthenticationManager는 ProviderManager의 인터페이스이다.나머지 인증 절차는 기존에 절차를 재활용할 수 있고, 마지막으로 성공적으로
Authentication이 나오면 이를 SecurityContext에 저장해주어야 한다.이렇게 기존 username/password 로그인을 재활용해서 REST API 방식에서 구현할 수 있다. 이렇게 만들면 REST API에서 username/password로 로그인이 되고, 로그인시 세션 id를 발급받을 수 있다.
세션 id가 발급되는 원리는
SecurityContextPersistenceFilter가 SecurityContext를 보고 세션을 알아서 발급하고 관리해주기 때문이다.UsernamePasswordAuthenticationFilter를 재활용 하면 안되나?
UsernamePasswordAuthenticationFilter는 POST /login 요청을 처리해준다. 그렇다면 REST API에서도 별도로 컨트롤러를 만들지 않고 UsernamePasswordAuthenticationFilter를 그냥 사용하면 되지 않을까?결론은, 상관없다. 그러나 가입 컨트롤러, 로그아웃 컨트롤러등 다른 API는 모두 컨트롤러로 구성되어 있기에 일관성이 떨어진다.
그리고 이후에 로그인시에 추가적인 로직을 넣을 때도 확장성이 떨어지게 된다.
무엇보다
UsernamePasswordAuthenticationFilter만 쓰고 DefaultLoginPageGeneratingFilter을 안쓰는 건 어렵기에 GET /login 페이지를 없애려면 폼 로그인을 비활성화해서 둘다 같이 비활성화해야 한다는 점이 있다.JWT 토큰을 발급하려면?
sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) 로 세션 로그인을 종료하자.