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

2. 환경
- SpringBoot
- Gradle
- Mybatis
3. 스프링 시큐리티 인증 절차
- 스프링 시큐리티를 사용하면 여러 인증 및 인가 필터를 거친다. 필터를 거칠때마다 이와 관련된 여러 인터페이스 구현체를 거치게 되는데, 이걸 우리의 서비스에 맞게 커스텀하면 된다. 그러려면? 내부적으로 어떤 클래스(절차)들을 거치는지 알아볼 필요가 있다.
SpringSecurity Form 인증 절차 - UsernamePasswordAuthenticationFilter > AuthenticationManager > AuthenticationProvider > UserDetailsService > Repository(DB)
- UsernamePasswordAuthenticationFilter에서 요청값에 대한 Authentication 객체를 생성한다.
- 생성한 Authentication 객체를 AuthenticationManager에게 전달한다.
- AuthenticationManager는 실제 인증 처리를 하지 않고, 적절한 인증 처리 클래스에게 인증 처리를 위임하는, 말 그대로 매니저 역할을 하는 클래스이다. 폼 인증 요청에 대한 적절한 인증 처리 클래스인 AuthenticationProvider에게 Authentication 객체를 전달하여 인증 처리를 위임한다.
- AuthenticationProvider는 들어온 Authentication 객체의 username을 값을 추출하여 UserDetailsService의 loadUserByUsername 메서드 호출에 사용한다. 말 그대로 유저 ID에 대한 유저 정보를 조회한다.
- UserDetailsService는 username 값을 통해 DB에서 유저 상세 정보(아이디, 비밀번호, 권한 등)를 UserDetails 타입으로 조회한다.
- 조회한 UserDetilas 타입의 객체를 AuthenticationProvider에게 return한다.
- AuthenticationProvider는 DB에서 조회한 비밀번호와 요청으로 들어온 비밀번호를 체크한다. 만약 암호화 된 비밀번호라면 암호화 클래스를 사용해 체크한다.
- 비밀번호가 일치할 경우 인증 토큰에 들어갈 권한 객체인 authorities를 생성 후 원하는 권한을 넣어준다.
- AuthenticationProvider는 유저 정보 + authorities를 담은 Authentication 객체를 생성하여 AuthenticationManager에게 return한다.
- AuthenticationManager는 Authentication 객체를 UsernamePasswordAuthenticationFilter에게 return 한다.
- 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. 마치며
- 스프링 시큐리티 폼 인증 절차에 대해 개념을 다시 정리할 수 있었던 좋은 기회였다. 또한 내가 작성한 로직들을 포스팅을 통해 재확인해보니 수정할 곳도 몇몇 보였다. 한동안 이핑계 저핑계로 포스팅을 하지 않아 쌓여있는 내용이 엄청 많아졌다. 천천히 조금씩 정리해나가야겠다.
'백엔드 > SpringSecurity' 카테고리의 다른 글
[Spring Security] ROLE_USER 에 대해 403 에러가 발생하는 이유 (5) | 2023.10.10 |
---|---|
[SpringSecurity] OAuth2Login과 UsernamePasswordAuthenticationFilter의 관계 / JWT 필터 위치 (0) | 2023.09.18 |
[SpringSecurity] CSRF란? / CSRF Filter 처리 방식 (4) | 2022.07.18 |