반응형

1. 개요

  • 토이 프로젝트 진행 중 관리자 권한에 따른 기능 분리가 필요하여 기존 Gradle 프로젝트에 SpringSecurity 인증을 끼얹어보았다.
  • 스프링 시큐리티 온라인 강의를 들은 적이 있다. 이해를 할때마다 고개를 끄덕거리며 만족해하던 내 자신이 기억났다. '생겨버렸다. 자신감'. 하지만 정작 기억해야할 건 전혀 기억하지 못하더라... 자신감은 잠시 버려두고 강의노트를 펼쳤다.

 


2. 환경

  • SpringBoot
  • Gradle
  • Mybatis

3. 스프링 시큐리티 인증 절차

  • 스프링 시큐리티를 사용하면 여러 인증 및 인가 필터를 거친다. 필터를 거칠때마다 이와 관련된 여러 인터페이스 구현체를 거치게 되는데, 이걸 우리의 서비스에 맞게 커스텀하면 된다. 그러려면? 내부적으로 어떤 클래스(절차)들을 거치는지 알아볼 필요가 있다. 
    SpringSecurity Form 인증 절차
     
  • UsernamePasswordAuthenticationFilter > AuthenticationManager > AuthenticationProvider > UserDetailsService > Repository(DB)
    1. UsernamePasswordAuthenticationFilter에서 요청값에 대한 Authentication 객체를 생성한다.
    2. 생성한 Authentication 객체를 AuthenticationManager에게 전달한다.
    3. AuthenticationManager는 실제 인증 처리를 하지 않고, 적절한 인증 처리 클래스에게 인증 처리를 위임하는, 말 그대로 매니저 역할을 하는 클래스이다. 폼 인증 요청에 대한 적절한 인증 처리 클래스인 AuthenticationProvider에게 Authentication 객체를 전달하여 인증 처리를 위임한다.
    4. AuthenticationProvider는 들어온 Authentication 객체의 username을 값을 추출하여 UserDetailsService의 loadUserByUsername 메서드 호출에 사용한다. 말 그대로 유저 ID에 대한 유저 정보를 조회한다.
    5. UserDetailsService는 username 값을 통해 DB에서 유저 상세 정보(아이디, 비밀번호, 권한 등)를  UserDetails 타입으로 조회한다.
    6. 조회한 UserDetilas 타입의 객체를 AuthenticationProvider에게 return한다.
    7. AuthenticationProvider는 DB에서 조회한 비밀번호와 요청으로 들어온 비밀번호를 체크한다. 만약 암호화 된 비밀번호라면 암호화 클래스를 사용해 체크한다.
    8. 비밀번호가 일치할 경우 인증 토큰에 들어갈 권한 객체인 authorities를 생성 후 원하는 권한을 넣어준다.
    9. AuthenticationProvider는 유저 정보 + authorities를 담은 Authentication 객체를 생성하여 AuthenticationManager에게 return한다.
    10. AuthenticationManager는 Authentication 객체를 UsernamePasswordAuthenticationFilter에게 return 한다.
    11. UsernamePasswordAuthenticationFilter는 Authentication 객체를 SecurityContext에 저장한다.

4. 구현

  • 필자가 정리한 위 인증 절차에서 커스텀해야할 녀석은 누구일까? 당연히 실질적인 인증처리를 하고있는 녀석들을 커스텀해야한다. AuthenticationProvider와 UserDetailsService가 핵심이다. 둘 다 인터페이스이므로 구현체를 생성하여 주입시켜주기만 하면 된다. SecurityConfig 부터 시작하자.

 

1) SecurityConfig.java

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter{
	
	@Autowired
	private AuthenticationSuccessHandler authenticationSuccessHandler;
	
	@Autowired
	private AuthenticationFailureHandler authenticationFailureHandler;
	
	@Bean
	public PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}
	
	@Override
	protected void configure(HttpSecurity http) throws Exception {

		http
			.csrf().disable(); //일반 사용자에 대해 Session을 저장하지 않으므로 csrf을 disable 처리함.
		
		http
			.authorizeRequests()
			.antMatchers("/admin").permitAll()
			.antMatchers("/admin/**").hasRole("ADMIN")
			.antMatchers("/swagger-ui.html").hasRole("ADMIN")
			.antMatchers("/schedule/insert","/schedule/update","/schedule/delete").hasRole("ADMIN")
			.antMatchers("/comment/insert","/comment/update","/comment/delete").hasRole("ADMIN")
			.anyRequest().permitAll();
		
		http
			.formLogin()
			.loginPage("/admin")
			.loginProcessingUrl("/admin/login")
			.usernameParameter("username")
			.passwordParameter("password")
			.successHandler(authenticationSuccessHandler)
			.failureHandler(authenticationFailureHandler);
			
		http
			.logout()
			.logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
			.logoutSuccessUrl("/admin")
			.invalidateHttpSession(true);
	
	}	
}

 - csrf().disable()

  •  먼저 csrf는 Cross Site Request Forgery의 약자로 사용자가 자신의 의지와는 무관하게 공격자가 의도한 행위를 특정 웹사이트에 요청하게하는 공격을 말한다.
  •  예를들어 은행 홈페이지에 특정 사용자에게 송금을 시키는 API(test.com/transMoney?money={송금할 금액}&to={송금받을 유저})가 있다고 하자.  A라는 공격자는 해당 홈페이지를 사용하는 유저들에게 다음과 같은 이미지 태그를 포함한 메일을 전송한다.
<img src="test.com/transMoney?money=100000&to=A />
  • 이 메일을 열어본 사용자는 src에 기입된 API가 호출되게 된다. 만약 해당 홈페이지에 로그인하여 세션이 브라우저에 남아있는 상태라면 공격자 A에게 100000원이 송금되는 것이다. 이런 공격을 csrf 공격이라고 한다.
  • http.csrf() 구문을 사용하면 클라이언트가 서버 통신 시 csrf 토큰 값을 함께 전달하고, 서버는 토큰에 대한 유효성을 체크하여 일치하지 않을 경우 요청을 차단하게 된다. 위 API를 호출한다 해도 csrf 토큰 값이 없으므로 서버에서는 401 에러가 발생할것이다. 하지만 내가 만든 서비스에서는 이러한 방식에 대한 필요성을 느끼지 못해 disable 처리를 하였다.

 

 - authorizeRequest() ~ anyRequest().permitAll()

  • 요청에 대한 인가를 설정한다. 현재 필자의 토이프로젝트 권한에 맞게 설정하였으므로 참고만 하고 넘어가면 된다.

 

 - formLogin()

  • formLogin 인증 방식을 사용한다는 의미이다. 

 

 - usernameParameter("username")와 passwordParameter("password")

  • 클라이언트가 전송한 폼 데이터 중 "username"과 "password"라는 name을 가진 값을 스프링 시큐리티에서 username, password로 사용한다. 즉, UsernamePasswordAuthenticationFilter 에서 Authentication 객체를 생성할때 각각의 변수 값을 사용한다.
<form action="/admin/login" id="form" method="post">
	<input type="password" name="password" id="adminPassword" />
    <input type="hidden" name="username" id="adminId" />
    <input type="submit" class="loginBtn" value="로그인">
</form>

 

 - loginPage()

  • 로그인할 페이지의 주소를 입력한다.

 

 - loginProcessingUrl()

  • 로그인을 처리할 Url을 입력한다.

 

 - successHandler()

  • 인증이 성공한 후 호출되는 핸들러 클래스이다.

  

 - failureHandler()

  • 인증이 실패한 후 호출되는 핸들러 클래스이다.

 

 - logout() ~ invalidateHttpSession()

  • /logout 을 호출하면 /admin 페이지로 이동하며 로그아웃이 되며, 이와 동시에 session이 무효화(invaildate)된다.

 

2) CustomAuthenticationSuccessHandler.java

@Component
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler{

	private final Logger logger = LoggerFactory.getLogger(getClass());
	
	@Override
	public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
			Authentication authentication) throws IOException, ServletException {
		
		logger.info("admin login Success !!");
		response.sendRedirect("/");
	}

}

 

  • AuthenticationSuccessHandler 인터페이스의 구현체이다.
  • 로그인이 성공할 경우 메인 페이지("/")로 리다이렉트 되도록 하였다.

 

3) CustomAuthenticationFailureHandler.java

@Component
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler{

	private final Logger logger = LoggerFactory.getLogger(getClass());
	
	@Override
	public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
			AuthenticationException exception) throws IOException, ServletException {
		
		logger.error("admin login Failed !!");
		response.sendRedirect("/admin?auth=fail");
	}

}
  • AuthenticationFailureHandler 인터페이스의 구현체이다.
  • 로그인이 실패할 경우 쿼리 스트링을 포함하여 "/admin"로 이동시켰다.
  • 참고로 /admin 페이지는 관리자 로그인을 시도하는 페이지이며, 클라이언트단에서 auth값에 따른 알림 메시지를 출력하기 위해 쿼리스트링을 포함시켰다.

 

4) CustomAuthenticationProvider.java

@Component
public class CustomAuthenticationProvider implements AuthenticationProvider{

	@Autowired
	private UserDetailsService userDetailsService; //CustomUserDetails Class Autowired.
	
	@Autowired
	private PasswordEncoder passwordEncoder; //BCryptPasswordEncoder Class Autowired.
	
	@Override
	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
		
		String username = authentication.getName();
		String password = (String)authentication.getCredentials();
		
		CustomUserDetails customUserDetails = (CustomUserDetails) userDetailsService.loadUserByUsername(username);
	
		if(!passwordEncoder.matches(password, customUserDetails.getPassword())) {
			throw new BadCredentialsException("비밀번호가 일치하지 않습니다.");
		}
				
		List<GrantedAuthority> authorities = new ArrayList<>();
		authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN"));
		
		return new UsernamePasswordAuthenticationToken(username,password,authorities);

	}

	
	@Override
	public boolean supports(Class<?> authentication) {
		return authentication.equals(UsernamePasswordAuthenticationToken.class);
	}

}
  • AuthenticationProvider 인터페이스의 구현체이다.
  • 요청에 대한 ID와 PW 값이 포함된 Authentication이라는 객체가 요청 파라미터 값으로 들어온다. 인증절차의 4번째 단계이다.
  • authentication 객체에서 username 값을 사용하여 userDetailsService.loadUserByUsername 메서드를 호출한다.
  • 참고로 userDetailsService 구현체 내에서는 username을 가진 admin 계정의 정보를 조회하는 기능을 한다.
  • 비밀번호가 일치하지 않을 경우 BadCredentialsException을, 일치할 경우 GrantedAuthority 리스트 내에 ROLE_ADMIN 권한을 추가시킨 후 UsernamePasswordAuthenticationToken 생성자를 호출해 return한다. 이 생성자를 호출할 경우 내부적으로 AbstractAuthenticationToken 객체를 생성하는데, 이 객체는 Authentication 객체의 구현체이다. 즉, 인증절차의 9번째 단계이다.
  • supprotes 메서드는 authenticate 메서드의 동작 여부를 결정한다. false를 return하면 동작하지 않는다. 위 코드에서는 Form 인증에 대한 authentication 객체. 즉 UsernamePasswordAuthenticationToken 자료형으로 요청이 들어올 경우에만 인증 절차를 진행한다.

* 필자의 경우 DB에서 계정에 대한 권한 정보를 조회하여 부여해주는 방식이 아닌, 비밀번호 일치 시 ADMIN 권한을 주도록 로직이 짜여져있다. 이 부분은 전자와 같이 동작하도록 로직을 수정이 필요함을 느꼈다. 만약 독자분들도 전자와 같이 구현하고자한다면 loadUserByUsername 메서드를 통해 권한 정보도 조회하도록 쿼리 및 CustomUserDetails 클래스 수정 후 "ROLE_ADMIN" 부분에 customUserDetails의 get 메서드로 권한 정보를 가져와서 넣어주면 될 것이다. 

 

5) CustomUserDetailsService

@Service
public class CustomUserDetailsService implements UserDetailsService{

	@Autowired
	private AdminRepository adminRepository;
	
	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		
		UserDetails userDetails = adminRepository.getUserDetails(username);
		
		if(userDetails == null) {
			throw new UsernameNotFoundException("유효하지 않는 로그인 정보입니다.");
		}
		
		return userDetails;
	}

}
  • UserDetailsService 인터페이스의 구현체이다.
  • AdminRepository는 Mybatis에 대한 Mapper 인터페이스이다.
  • loadUserByUsername 메서드를 오버라이드한 후 AdminRepository 인터페이스를 사용해 admin 계정에 대한 id, pw 정보를 UserDetails 자료형으로 조회 후 리턴한다.

 

6) CustomUserDetails

@Component
public class CustomUserDetails implements UserDetails{

	private static final long serialVersionUID = 1L;

	private String username;
	private String password;
	
	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		// TODO Auto-generated method stub
		return null;
	}

	@Override
	public String getPassword() {
		return this.password;
	}

	@Override
	public String getUsername() {
		return this.username;
	}

	@Override
	public boolean isAccountNonExpired() {
		return false;
	}

	@Override
	public boolean isAccountNonLocked() {
		return false;
	}

	@Override
	public boolean isCredentialsNonExpired() {
		return false;
	}

	@Override
	public boolean isEnabled() {
		return false;
	}
}
  • UserDetails 인터페이스의 구현체이다.

 

 

7) AdminRepository

@Repository
public interface AdminRepository {

	public UserDetails getUserDetails(String username);
}


// AdminMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 
<mapper namespace="com.cds.repository.AdminRepository">

    <select id = "getUserDetails" resultType = "CustomUserDetails">
    	SELECT
    		id AS username
    		,password
    	FROM
    		TB_ADMIN
    	WHERE
    		id = #{username}
    		AND use_yn = 'Y'
    </select>
</mapper>
  • AdminRepository 및 AdminMapper 쿼리이다.
  • returnType의 CustomUserDetails은 typeAlias를 사용해 네이밍을 간소화시켰다.

5. 테스트

1) 로그인 실패 시 

로그인 실패 시 로그
로그인 실패 시 리다이렉트

2) 로그인 성공 시

로그인 실패 시 로그

 

로그인 성공 시 리다이렉트


6. 마치며

  • 스프링 시큐리티 폼 인증 절차에 대해 개념을 다시 정리할 수 있었던 좋은 기회였다. 또한 내가 작성한 로직들을 포스팅을 통해 재확인해보니 수정할 곳도 몇몇 보였다. 한동안 이핑계 저핑계로 포스팅을 하지 않아 쌓여있는 내용이 엄청 많아졌다. 천천히 조금씩 정리해나가야겠다.
반응형

+ Recent posts