1. 개요
앞의 게시글에서 json을 사용한 로그인을 스프링 시큐리티를 사용하여 구현해보았다. 구글 코드를 참고하여 작성하다가 해당 코드를 보았다.
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);//principal 과 credentials 전달
return this.getAuthenticationManager().authenticate(authRequest);
해당 코드가 실제로 어떻게 동작하여 인증을 진행하는 지 궁금하여 이 게시글을 작성해보면서 공부하기로 하였다.
https://runawayfromlazy.tistory.com/16
Hello, Spring Security
1. 스프링 시큐리티란? 인증, 인가 등 스프링 웹 개발 시 필요한 사용자 관리 기능을 구현하는데 도움을 주는 프레임워크이다. 이를 사용함으로써 보안 관련 기능을 보다 간편하고 빠르게
runawayfromlazy.tistory.com
이전에 스프링 시큐리티를 처음 접하면서 이의 동작 과정을 간단히 기술했던 글이다. 프로젝트도 어느 정도 진행되었고, 익숙해지기도 해서 이번엔 코드를 보면서 직접 이해하며 복습하는 느낌이다.
2. authenticationmanager
앞서 보았듯이 username, password를 받아 autnenticationmanager.authenticate()를 진행하여 결과를 리턴한다.
AuthenticationManager는 인터페이스이다.
여기서 볼 수 있듯이 authenticate()를 가지고 있고, 이는 Authentication 객체를 받고, Authentication 객체를 리턴한다.
로그인 필터에서 UsernamePasswordAuthenticationToken을 입력받는다.
해당 객체는 AbstractAuthenticationToken을 상속받고, 이는 다시 Authentication의 구현체임을 확인할 수 있었다.
이제 providerManager를 살펴보자 이 프로젝트에서는 DaoAuthenticationProvider를 사용하였다.
providerManager는 authenticationProvider에게 사용자 정보를 가져오도록 요청해야 한다.
public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
private static final Log logger = LogFactory.getLog(ProviderManager.class);
private AuthenticationEventPublisher eventPublisher;
private List<AuthenticationProvider> providers;
...
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
...
while(var9.hasNext()) {
AuthenticationProvider provider = (AuthenticationProvider)var9.next();
if (provider.supports(toTest)) {
...
try {
result = provider.authenticate(authentication);
if (result != null) {
this.copyDetails(authentication, result);
break;
}
}
//catch exception
}
}
if (result == null && this.parent != null) {
try {
parentResult = this.parent.authenticate(authentication);
result = parentResult;
}
//catch exception
}
if (result != null) {
if (this.eraseCredentialsAfterAuthentication && result instanceof CredentialsContainer) {
((CredentialsContainer)result).eraseCredentials();
}
if (parentResult == null) {
this.eventPublisher.publishAuthenticationSuccess(result);
}
return result;
} else {
if (lastException == null) {
lastException = new ProviderNotFoundException(this.messages.getMessage("ProviderManager.providerNotFound", new Object[]{toTest.getName()}, "No AuthenticationProvider found for {0}"));
}
if (parentException == null) {
this.prepareException((AuthenticationException)lastException, authentication);
}
throw lastException;
}
}
...
}
providerManager의 일부를 발췌하였다. 읽어보면 provider 리스트에서 provider를 꺼내어 authenticate를 수행하여 그 결과를 처리하는 것을 확인할 수 있었다.
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = this.determineUsername(authentication);
boolean cacheWasUsed = true;
UserDetails user = this.userCache.getUserFromCache(username);
if (user == null) {
cacheWasUsed = false;
try {
user = this.retrieveUser(username, (UsernamePasswordAuthenticationToken)authentication);
}
catch (UsernameNotFoundException var6) {
//catch exception
}
}
try {
this.preAuthenticationChecks.check(user);
this.additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken)authentication);
}
catch (AuthenticationException var7) {
//catch exception
}
this.postAuthenticationChecks.check(user);
if (!cacheWasUsed) {
this.userCache.putUserInCache(user);
}
Object principalToReturn = user;
if (this.forcePrincipalAsString) {
principalToReturn = user.getUsername();
}
return this.createSuccessAuthentication(principalToReturn, authentication, user);
}
상위 abstract class의 authenticate 함수이다. 예외 처리 부분은 제외했다. 중요한 부분을 읽어보자
user = this.retrieveUser(username, (UsernamePasswordAuthenticationToken)authentication);
retrieveUser를 통해 user 정보를 가져온다. 해당 메서드는 DaoAuthenticationProvider에 구현되어있다.
3. AuthenticationProvider
protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
this.prepareTimingAttackProtection();
try {
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
if (loadedUser == null) {
throw new InternalAuthenticationServiceException("UserDetailsService returned null, which is an interface contract violation");
} else {
return loadedUser;
}
} (//catch exception)
}
해당 메서드에서 UserDetailsService.loadUserByUsername을 통해 사용자 정보를 받아오는 것을 확인할 수 있다. 프로젝트에서 구현한 UserDetailsImpl이다.
public void check(UserDetails user) {
if (!user.isAccountNonLocked()) {
AbstractUserDetailsAuthenticationProvider.this.logger.debug("Failed to authenticate since user account is locked");
throw new LockedException(AbstractUserDetailsAuthenticationProvider.this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.locked", "User account is locked"));
} else if (!user.isEnabled()) {
AbstractUserDetailsAuthenticationProvider.this.logger.debug("Failed to authenticate since user account is disabled");
throw new DisabledException(AbstractUserDetailsAuthenticationProvider.this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.disabled", "User is disabled"));
} else if (!user.isAccountNonExpired()) {
AbstractUserDetailsAuthenticationProvider.this.logger.debug("Failed to authenticate since user account has expired");
throw new AccountExpiredException(AbstractUserDetailsAuthenticationProvider.this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.expired", "User account has expired"));
}
}
public void check(UserDetails user) {
if (!user.isCredentialsNonExpired()) {
AbstractUserDetailsAuthenticationProvider.this.logger.debug("Failed to authenticate since user account credentials have expired");
throw new CredentialsExpiredException(AbstractUserDetailsAuthenticationProvider.this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.credentialsExpired", "User credentials have expired"));
}
}
이후 check 함수를 통해 사용가능한 계정인지 확인한다. 각각 preAuthenticationCheck, postAuthenticationCheck이다.
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
if (authentication.getCredentials() == null) {
this.logger.debug("Failed to authenticate since no credentials provided");
throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
} else {
String presentedPassword = authentication.getCredentials().toString();
if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
this.logger.debug("Failed to authenticate since password does not match stored value");
throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
}
}
DaoiAuthentication.additionalAuthenticationChecks이다. 여기서 사용자의 비밀번호를 꺼내어 확인한다.
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
if (authentication.getCredentials() == null) {
this.logger.debug("Failed to authenticate since no credentials provided");
throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
} else {
String presentedPassword = authentication.getCredentials().toString();
if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
this.logger.debug("Failed to authenticate since password does not match stored value");
throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
}
}
이들을 모두 통과하면 마지막에 createSuccessAuthentication으로 인증에 성공한 상태를 리턴하게 된다. 이를 ProviderManager가 받고, 이를 리턴한다.
ProviderManager.class
if (result != null) {
if (this.eraseCredentialsAfterAuthentication && result instanceof CredentialsContainer) {
((CredentialsContainer)result).eraseCredentials();
}
if (parentResult == null) {
this.eventPublisher.publishAuthenticationSuccess(result);
}
return result;
}
4. Handler
AbstractAuthenticationProcessingFilter.class
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
if (!this.requiresAuthentication(request, response)) {
chain.doFilter(request, response);
} else {
try {
Authentication authenticationResult = this.attemptAuthentication(request, response);
if (authenticationResult == null) {
return;
}
this.sessionStrategy.onAuthentication(authenticationResult, request, response);
if (this.continueChainBeforeSuccessfulAuthentication) {
chain.doFilter(request, response);
}
this.successfulAuthentication(request, response, chain, authenticationResult);
} catch (InternalAuthenticationServiceException var5) {
InternalAuthenticationServiceException failed = var5;
this.logger.error("An internal error occurred while trying to authenticate the user.", failed);
this.unsuccessfulAuthentication(request, response, failed);
} catch (AuthenticationException var6) {
AuthenticationException ex = var6;
this.unsuccessfulAuthentication(request, response, ex);
}
}
}
다시 필터로 돌아온다. 현재 attemptAuthentication까지 완료된 상황이다. 해당 인증이 성공했다면 successfulAuthentication, 실패했다면 unsuccessfulAuthentication이 실행된다.
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
context.setAuthentication(authResult);
this.securityContextHolderStrategy.setContext(context);
this.securityContextRepository.saveContext(context, request, response);
if (this.logger.isDebugEnabled()) {
this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authResult));
}
this.rememberMeServices.loginSuccess(request, response, authResult);
if (this.eventPublisher != null) {
this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
}
this.successHandler.onAuthenticationSuccess(request, response, authResult);
}
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
this.securityContextHolderStrategy.clearContext();
this.logger.trace("Failed to process authentication request", failed);
this.logger.trace("Cleared SecurityContextHolder");
this.logger.trace("Handling authentication failure");
this.rememberMeServices.loginFail(request, response);
this.failureHandler.onAuthenticationFailure(request, response, failed);
}
해당 함수들이다. 성공한다면 successHandler, 실패하면 failureHandler로 넘겨 이를 처리하게 된다.
5. 빈 등록
@Bean
public static PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Bean
public DaoAuthenticationProvider daoAuthenticationProvider() throws Exception {
DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
daoAuthenticationProvider.setUserDetailsService(userDetailsService);
daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
return daoAuthenticationProvider;
}
@Bean
public AuthenticationManager authenticationManager() throws Exception {//AuthenticationManager 등록
DaoAuthenticationProvider provider = daoAuthenticationProvider();//DaoAuthenticationProvider 사용
provider.setPasswordEncoder(passwordEncoder());
return new ProviderManager(provider);
}
@Bean
public JsonUsernamePasswordAuthenticationFilter jsonUsernamePasswordLoginFilter() throws Exception {
JsonUsernamePasswordAuthenticationFilter jsonUsernamePasswordLoginFilter = new JsonUsernamePasswordAuthenticationFilter(objectMapper);
jsonUsernamePasswordLoginFilter.setAuthenticationManager(authenticationManager());
jsonUsernamePasswordLoginFilter.setAuthenticationSuccessHandler(loginSuccessJWTProvideHandler());
jsonUsernamePasswordLoginFilter.setAuthenticationFailureHandler(loginFailureHandler());
return jsonUsernamePasswordLoginFilter;
}
@Bean
public LoginSuccessJwtProviderHandler loginSuccessJWTProvideHandler(){
return new LoginSuccessJwtProviderHandler(jwtUtil, redisUtil);
}
@Bean
public LoginFailureHandler loginFailureHandler(){
return new LoginFailureHandler();
}
이에 따라 위 빈들을 등록하게 된 것이다. 로그인 성공, 실패 핸들러를 등록하고, 이를 로그인 필터에 등록하여 4번 과정을 진행토록 한다. 비밀번호 인코더를 등록하고, AuthenticationProvider에 주입하여 비밀번호 인증 시 사용할 수 있도록 하고, 이를 받을 ProviderManager를 등록해준다.
6. 후기
Json 로그인 구현부에 대해 찾아보니 스프링 시큐리티의 기본 기능을 다시 떠올리게 되었다. 처음 이를 사용할 때 동작 과정을 글로 적었었는데, 코드로 다시 보면서 해당 빈들이 왜 등록되어야 하는지, 어떤 순서로 동작하는 지 다시 한 번 되새기게 되었다. 한 번 공부하고 구현해본 것도 코드를 다시 보면서 동작 과정을 제대로 익힐 수 있게 된 것 같다.
'Spring > Security' 카테고리의 다른 글
[Spring Security] 소셜 로그인 과정 이해하기 (Feat. Kakao) (0) | 2024.09.30 |
---|---|
Hello, Spring Security (1) | 2024.03.27 |