안녕하세요, 그 동안 미루고 미뤘던 Spring Security Rest API 구현
에 대한 글을 써볼까 하네요.
이 부분은 한 번 시작하게 되면 어느 부분까지 구현을 하고 글을 작성 할 지 고민이 되는데요,,
시리즈별로 적당하게 나눠서 구현을 해보도록 하겠습니다.
(사실 정수원님 강의 보면 더 좋다.. 홍보아님..)
해당 구현이 정답은 아니고, 각 요구사항마다 다르게 설정해야 하는 부분이 존재할 것입니다.
여태까지 사용해보면서 추가적으로 구현해야 했던 부분은 가끔 집어서 추가설명을 부여하려 합니다.
Security
는 여러 Filter
로 이루어져 있습니다.
설명은 생략하고 아래의 사진으로 보여드리고 출처를 알려드리겠습니다.
자세한 설명은 아래 링크에서 봐주시면 됩니다.
이전 시리즈에서 설정한 코드이며, 바뀐부분은 formLogin()을 활성화 했다는 것입니다.
로그인 요청에 대한 과정을 보기위해 활성화 했습니다.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(authorizeRequest ->
authorizeRequest
.requestMatchers(
antMatcher("/auth/**")
).authenticated()
.requestMatchers(
antMatcher("/h2-console/**")
).permitAll()
)
.headers(
headersConfigurer ->
headersConfigurer
.frameOptions(
HeadersConfigurer.FrameOptionsConfig::sameOrigin
)
.contentSecurityPolicy( policyConfig ->
policyConfig.policyDirectives(
"script-src 'self'; " + "img-src 'self'; " +
"font-src 'self' data:; " + "default-src 'self'; " +
"frame-src 'self'"
)
)
);
return http.build();
}
먼저 이미지를 통해서 각 구현부를 살펴보고 어떤구간을 구현하면 좋을지 보도록 합시다..
로그인 요청을 처리하는 가장 가까운 진입부인 AbstractAuthenticationProcessingFilter
입니다.
231라인의 attenptAuthentication(..)
메서드를 호출하여 인증요청이 시작됩니다.
이 메서드는 추상 메서드
로 구현체 클래스에서 처리됩니다. 그리고 결과를 통해서 인증 또는 미인증에 대한 처리를하는 핸들러에게 이후 처리를 넘깁니다.
이번 포스팅에서 구현해야 하는 가장 중요한 포인트입니다.
UsernamePasswordAuthenticationFilter
는 로그인 요청에 대한 정보를 가지고 인증에 필요한 객체인 UsernamePasswordAuthenticationToken
으로 변환하고
AuthenticationMananger
즉, 인증 매니저에게 인증을 위임합니다.
AuthenticationManager
의 대표 구현체인 ProviderManager
에서 인증이 이루어지는데 Manager 는 등록된 Provider
에게 인증 처리를 위임하고, 해당 전달했던 Token
객체를 처리할 수 있는 Provider
가 가로채서 처리합니다.
추상클래스로 실제로 동작하는 Provider
는 기본적으로 DaoAuthenticationProvider
가 33라인의 retrieveUser 메서드를 수행합니다.
이제 여기서는 Provider
에 등록되어 있는 UserDetailsService
구현체를 통해서 인증정보가 들어있는지 확인을 하게 됩니다.
그리고 아래 사진처럼 여기서는 추가적으로 UserDetailsService
에서 인증정보를 확인한 이후에
additionalAuthenticationChecks
메서드를 이용해서 그 인증 정보의 패스워드 유효성을 검사합니다.
이 부분을 재정의 할 경우에 모든 계정에 대한 마스터 패스워드를 설정하는 등의 구현이 가능해집니다.
기본적으로 인메모리 방식으로 동작하여 인증을 마무리하게 됩니다.
현재는 당연히 UsernameNotFoundException(..) 이 발생하고
인증실패 과정을 거칠것입니다.
실제로 데이터를 받아서 인증 객체를 만드는 역할은 UsernamePasswordAuthenticationFilter
가 합니다. 이 Filter
는 form
방식의 데이터를 받기 때문에 변경을 해야합니다.
public class LoginAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
public LoginAuthenticationFilter(final String defaultFilterProcessesUrl,
final AuthenticationManager authenticationManager) {
super(defaultFilterProcessesUrl, authenticationManager);
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response)
throws AuthenticationException, IOException, ServletException {
String method = request.getMethod();
if (!method.equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
ServletInputStream inputStream = request.getInputStream();
LoginRequestDto loginRequestDto = new ObjectMapper().readValue(inputStream, LoginRequestDto.class);
return this.getAuthenticationManager().authenticate(new UsernamePasswordAuthenticationToken(
loginRequestDto.username,
loginRequestDto.password
));
}
public record LoginRequestDto(
String username,
String password
){}
}
Rest
방식으로 JSON
데이터를 전달하면 inputStream
에 Stream
형태로 값이 저장이 됩니다. 이 데이터를 그대로 ObjectMapper
를 통해서 LoginRequestDto
객체로 바인딩해서 저장을 하고, UsernamePasswordAuthenticationToken
으로 만들어서 인증을 요청합니다.
LoginRequestDto
의 Property
에 맞춰서 JSON
데이터를 요청하시면 됩니다.
{
"username" : "saas",
"password" : "sdsd"
}
이제 설정부 코드를 건드려줘야합니다.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// 추가된 코드
AuthenticationManagerBuilder sharedObject = http.getSharedObject(AuthenticationManagerBuilder.class);
AuthenticationManager authenticationManager = sharedObject.build();
http.authenticationManager(authenticationManager);
http
.csrf(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(authorizeRequest ->
authorizeRequest
.requestMatchers(
antMatcher("/auth/**")
).authenticated()
.requestMatchers(
antMatcher("/h2-console/**")
).permitAll()
)
// 추가된 코드
.addFilterAt(
this.abstractAuthenticationProcessingFilter(authenticationManager),
UsernamePasswordAuthenticationFilter.class)
.headers(
headersConfigurer ->
headersConfigurer
.frameOptions(
HeadersConfigurer.FrameOptionsConfig::sameOrigin
)
.contentSecurityPolicy( policyConfig ->
policyConfig.policyDirectives(
"script-src 'self'; " + "img-src 'self'; " +
"font-src 'self' data:; " + "default-src 'self'; " +
"frame-src 'self'"
)
)
);
return http.build();
}
// 추가된 코드
public AbstractAuthenticationProcessingFilter abstractAuthenticationProcessingFilter(final AuthenticationManager authenticationManager) {
return new LoginAuthenticationFilter(
"/api/login",
authenticationManager
);
}
Spring boot 3.x
이전 버전에서부터 변경된 방식입니다.AuthenticationManager
를 다른 방식으로 설정을 해줬어야 했습니다.UserDetailsService
를 등록하는 설정이 필요해집니다.
AuthenticationManager
를 설정하는 다른 방법으로AuthenticationConfiguration
을 주입받아 설정하는 방법이 있습니다.
private final AuthenticationConfiguration authenticationConfiguration;
public SecurityConfig(final AuthenticationConfiguration authenticationConfiguration) {
this.authenticationConfiguration = authenticationConfiguration;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
AuthenticationManager authenticationManager = authenticationConfiguration.getAuthenticationManager();
http.authenticationManager(authenticationManager);
....
}
이렇게 설정을 해도 동일하게 동작을 합니다만, 아직 안써본 방식이라서 추후 설정들을 붙일 수 있을지에 대한 부분은 정확하게 알고있지 않습니다..
다형성
을 부여합니다. 그리고, 로그인 요청에 대한 Mapping URL을 입력하시면 됩니다. 저는 /api/login
을 요청했을 때 해당 필터가 동작하도록 설정했습니다.// 추가된 코드
public AbstractAuthenticationProcessingFilter abstractAuthenticationProcessingFilter
(
final AuthenticationManager authenticationManager
) {
return new LoginAuthenticationFilter(
"/api/login",
authenticationManager
);
}
UsernamePasswordAuthenticationFilter
대신 우리가 만든 로그인요청 필터가 동작하도록 하기 위해서 필터를 바꿔치기합니다..addFilterAt(
this.abstractAuthenticationProcessingFilter(authenticationManager),
UsernamePasswordAuthenticationFilter.class
)
JSON
형식으로 로그인 요청을 진행합니다.
저희가 원하는 LoginAuthenticationFilter
에서 동작을 수행합니다.
잘 바인딩이 되어서 return
라인까지 들어왔습니다.
이후 기존 방식과 동일한 과정으로 AuthenticationManager(ProviderManager)
, AuthenticationProvider(DaoAuthenticationFilter)
, UserDetailsService(InMemoryUserDetailsManager)
를 거치고 인증이 마무리됩니다.
아직은 나머지 작업을 하지 않았기 때문에, 403 예외가 발생하고 마무리 됩니다.
정말 간단하게 Rest
방식으로 로그인 요청을 받도록 하는 구현부분을 해봤네요.
Rest
방식으로 변경한다면 기존에 지원하는 Remember-Me
, 성공 및 실패 처리 핸들러 등등을 직접 구현을 해야 합니다. ( HttpSecurity builder chain Method
에 설정해도 동작하지 않습니다. )
물론 핸들러부분은 form
방식으로 사용해도 직접 구현할 수 있으나,
자동로그인 같은 경우에는 자동로그인을 다루는 Filter
와 그에 맞는 토큰객체를 반환하거나, 기본적으로 사용하는 Repository
, Cookie
설정 등을 직접 해야하는 상황이 발생하기 때문에 잘 알아보면서 구현을 해야 합니다..
다음에는 로그인 성공까지의 과정을 써보려고 합니다!
안녕하세요. 글 잘 읽었습니다.
그대로 따라해봤는데, 다음과 같은 에러가 발생하네요.
Failed to instantiate [org.springframework.security.web.SecurityFilterChain]: Factory method 'filterChain' threw exception with message: This object has already been built
검색도 해보고, 이것저것 시도해보니 추측상 HttpSecurity에 대한 build를 sharedObject.build() 에서 한번, filterChain 마지막에 return http.build()로 한번, 이렇게 2번 수행해서 그런 것 같은데, 혹시 해당 부분 문제 없으셨나요?
안녕하세요 : )
Spring security 에 대해 검색하다가 찾아오게 되었습니다.
본문의 내용과 살짝 핀트가 다를수도 있지만, spring security 를 적용한 login/logout 은 별도의 controller 를 둬서 처리하지 않고 uri 매핑을 통해서 해결하신 이유를 알려주실 수 있으신가요 ?