참고 : https://mangkyu.tistory.com/77
참고 : https://anjoliena.tistory.com/108
참고 : https://velog.io/@dailylifecoding/spring-security-logout-feature
package com.ghkwhd.shop.config;
import com.ghkwhd.shop.filter.CustomAuthenticationFilter;
import com.ghkwhd.shop.handler.CustomAccessDeniedHandler;
import com.ghkwhd.shop.handler.CustomLoginFailureHandler;
import com.ghkwhd.shop.handler.CustomLoginSuccessHandler;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import javax.servlet.http.HttpSession;
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
private final UserDetailsService userDetailsService;
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
// 로그인 성공 시 동작할 핸들러 등록
@Bean
public CustomLoginSuccessHandler customLoginSuccessHandler() {
return new CustomLoginSuccessHandler();
}
// 접근 권한 오류 처리 핸들러 등록
@Bean
public CustomAccessDeniedHandler customAccessDeniedHandler() {
return new CustomAccessDeniedHandler();
}
// 로그인 실패 처리 핸들러
@Bean
public CustomLoginFailureHandler customLoginFailureHandler() {
return new CustomLoginFailureHandler();
}
@Bean
public CustomAuthenticationProvider customAuthenticationProvider() {
return new CustomAuthenticationProvider(userDetailsService, bCryptPasswordEncoder());
}
// Manager 에 Provider 등록
@Override
public void configure(AuthenticationManagerBuilder authenticationManagerBuilder) {
authenticationManagerBuilder.authenticationProvider(customAuthenticationProvider());
}
@Bean
public CustomAuthenticationFilter customAuthenticationFilter() throws Exception {
CustomAuthenticationFilter customAuthenticationFilter = new CustomAuthenticationFilter(authenticationManager());
// 로그인 요청 url 을 정의, Controller 따로 생성할 필요 없음, loginProcessingUrl 과 동일
customAuthenticationFilter.setFilterProcessesUrl("/login");
customAuthenticationFilter.setAuthenticationSuccessHandler(customLoginSuccessHandler());
// 실패 핸들러 등록, configure() 에 failureHandler 에 적으면 핸들러가 동작 안함
customAuthenticationFilter.setAuthenticationFailureHandler(customLoginFailureHandler());
customAuthenticationFilter.afterPropertiesSet();
return customAuthenticationFilter;
}
@Override
public void configure(HttpSecurity http) throws Exception {
// 회원가입
http.authorizeRequests()
.antMatchers("/", "/signUp", "/js/**", "/loginHome").permitAll() // js 실행을 위해 필요
.antMatchers("/admin/**").access("hasRole('ROLE_ADMIN')") // admin 으로 시작하는 요청은 모두 ADMIN 권한 필요
.anyRequest().authenticated(); // 그 외 모든 요청은 인증이 필요
// 로그인
http.csrf().disable()
.formLogin()
.loginPage("/loginHome") // 로그인 페이지 url ( 요청 url )
.usernameParameter("id") // 이메일이 아닌 id 로 로그인을 할 것이기 때문에 설정
.permitAll()
.and()
// 커스텀 필터 등록
.addFilterBefore(customAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
.exceptionHandling()
.accessDeniedHandler(customAccessDeniedHandler()); // 접근 권한 오류 처리 핸들러 등록
}
}
이전 게시글에서 보았던 대부분의 것들을 직접 구현하여 등록하는 과정을 거쳤습니다. 저렇게 직접 @Bean 을 이용해서 등록해도 되고, 자동으로 주입받는 방식을 사용해도 괜찮습니다.
필터의 경우, configure(HttpSecurity http)
에 등록 하고, AuthenticationManger 에 Provider 를 등록할 때는 configure(AuthenticationManagerBuilder authenticationManagerBuilder)
에 등록합니다.
로그인 성공 시 동작할 핸들러와 실패 시 동작할 핸들러를 등록하는데 이는 필터에 등록을 합니다. configure()
에 등록할 수 있지만 이미 필터를 등록했기 때문에 configure 에서 등록을 하게 되면 핸들러가 제대로 동작하지 않습니다.
접근 권한이 없는 페이지에 접속했을 때 동작할 핸들러는 configure()
에 .exceptionHandling()
과 함께 등록합니다.
그 외 configure 의 내용은 주석으로 달아놓았기 때문에 설명은 생략하도록 하겠습니다.
public class CustomAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
public CustomAuthenticationFilter(AuthenticationManager authenticationManager) {
super.setAuthenticationManager(authenticationManager);
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
request.getParameter("id"), request.getParameter("password"));
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
}
Filter 는 지정된 URL 로 POST 방식으로 HTTP 요청이 오면 가장 먼저 수행되는 곳입니다. request 에 담긴 아이디와 비밀번호를 꺼내어 UserPasswordAuthenticationToken 을 생성합니다. 이때는 인증이 완료되지 않은 객체입니다.
그리고 흐름에 따라서 인증이 완료되지 않은 이 객체를 인증을 위해 AuthenticationManager 에게 전달합니다.
public class CustomLoginSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
SecurityContextHolder.getContext().setAuthentication(authentication);
/**
* 기존에는 로그인 성공 시 url 을 WebSecurityConfig 에서 지정
* 성공 handler 를 만들었으니 이곳에서 리다이렉트 설정
*/
response.sendRedirect("/userHome");
}
}
CustomLoginSuccessHandler는 AuthenticationProvider를 통해 인증이 성공될 경우 실행됩니다.
로그인이 성공하여 반환된 Authentication 객체를 SecurityContextHolder 를 통해 Context 를 조회하여, Context 에 저장합니다.
resopnse.sendRedirect
는 configure 에서 사용할 수 있는 .defaultSuccessUrl()
과 동일한 동작을 합니다.
@RequiredArgsConstructor
public class CustomAuthenticationProvider implements AuthenticationProvider {
private final UserDetailsService userDetailsService;
private final BCryptPasswordEncoder passwordEncoder;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication;
String id = token.getName();
String password = (String) token.getCredentials();
// UserDetailsService 를 통해 DB 에서 아이디로 사용자 조회
UserDetailsImpl userDetailsImpl = (UserDetailsImpl) userDetailsService.loadUserByUsername(id);
if (!passwordEncoder.matches(password, userDetailsImpl.getPassword())) {
throw new BadCredentialsException(userDetailsImpl.getUsername() + "Invalid password");
}
return new UsernamePasswordAuthenticationToken(userDetailsImpl, password, userDetailsImpl.getAuthorities());
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(UsernamePasswordAuthenticationToken.class);
}
}
AuthenticationFilter 는 생성한 UsernamePasswordToken 을 AuthenticationManager 에게 전달합니다. 그리고 Manager 는 실제로 인증을 처리할 Provider 에게 UsernamePasswordToken 을 전달하게 됩니다.
Provider 에서 실제 인증을 수행하는 부분은 authenticate()
이며, Username으로 DB에서 데이터를 조회한 다음에, 비밀번호의 일치 여부를 검사하는 방식으로 동작합니다.
Username 으로 DB 에서 조회하는 것은 UserDetailsService 의 loadUserByUsername()
메서드를 이용하며, 조회한 유저의 비밀번호와 일치한다면 인증이 완료된 UsernamePasswordAuthenticationToken 을 생성해 반환합니다.
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String id) throws UsernameNotFoundException {
return userRepository.findById(id)
.map(user -> new UserDetailsImpl(user, Collections.singleton(new SimpleGrantedAuthority(user.getRole().getValue()))))
.orElseThrow(() -> new UsernameNotFoundException("등록되지 않은 사용자입니다"));
}
}
Username 으로 User 정보를 찾고, UserDetails 를 구현한 UserDetailsImpl 을 반환합니다.
이렇게 반환된 객체가 아까 위에서 언급했듯이 Provider 에게 전달되고, 인증이 완료되면 인증된 UsernamePasswordAuthenticationToken 을 AuthenticationFilter 에 전달합니다.
AuthenticationFilter 에서는 LoginSuccessHandler 로 전달하고, LoginSuccessHandler 로 넘어온 Authentication 객체를 SecurityContextHolder 에 저장하면 인증 과정이 끝나게 됩니다.
@Override
public void configure(HttpSecurity http) throws Exception {
...
// 로그아웃
http.logout()
.logoutUrl("/logout") // 로그인과 마찬가지로 POST 요청이 와야함
.addLogoutHandler(((request, response, authentication) -> {
HttpSession session = request.getSession();
if (session != null) {
session.invalidate(); // 세션 삭제
}
}))
.logoutSuccessHandler(((request, response, authentication) -> {
response.sendRedirect("/");
}));
}
로그아웃 역시, 로그인과 마찬가지로 POST 요청이 와야 로그아웃 핸들러가 수행됩니다. 이 말은 화면에서 로그아웃 버튼을 눌렀을 때 해당 URL 에 POST 방식으로 요청하는 것을 구현해야한다는 의미입니다.
로그아웃 핸들러가 동작하면 세션을 삭제하도록 하였고, 로그아웃이 정상적으로 이루어지면 로그아웃이 정상적으로 수행되었을 때 실행할 핸들러가 리다이렉트 시키게 됩니다.
실제로는 LogoutFilter 가 세션 무효화를 처리해주기 때문에 직접 구현할 필요는 없습니다. 추가로 .deleteCookies()
를 통해 쿠키 삭제도 가능합니다.
<!-- 권한에 따른 페이지 설정 -->
<li class="nav-item" sec:authorize="hasRole('ROLE_ADMIN')"> <!-- 관리자만 보이도록 -->
<a class="nav-link active" aria-current="page" href="/admin/members">Members</a>
</li>
<form action="/logout" method="post">
<!-- @AuthenticationPrincipal 과 model 을 이용해서 전달하는 것과 동일 -->
<span style="font-size: large; font-weight: bold" sec:authentication="name"></span><span>님</span>
<button class="w-70 btn btn-secondary" type="submit">logout</button>
</form>
저는 화면을 thymeleaf 로 작성했습니다. thymeleaf 에서 sec:authentication
같은 것들이 동작하게 하려면 아래처럼 의존성을 추가해야합니다.
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5' // thymeleaf 에서 Spring Security 정보 사용
sec:authorize
와 sec:authentication
를 사용하여 로그인 정보 가져와 권한에 따라 페이지를 다르게 보이게 한다던지, 화면에 유저의 이름을 띄운다던지 작업을 할 수 있다 정도만 보고 넘어가도록 하겠습니다.
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
String redirectUrl = "/accessDenied";
// 쿼리 파라미터로 에러 메세지를 넘긴다
String queryParameter = "?exception=" + accessDeniedException.getMessage() + "&status=" + HttpStatus.FORBIDDEN.value();
response.sendRedirect(redirectUrl + queryParameter);
}
}
configure()
를 보면 알 수 있듯이 접근 거부가 나타났을 때 ( 403 Error ) 이를 처리하는 핸들러를 구현해서 등록했습니다.
핸들러 내부에서 에러가 발생하면 리다이렉트 시킬 URL 을 지정하고, 화면에 에러 메세지를 띄우기 위해 쿼리 파라미터를 통해 메세지를 작성하여 이를 리다이렉트 URL 로 사용했습니다.
@Controller
public class ErrorController {
@GetMapping("/accessDenied")
public String accessDenied(@RequestParam(value="exception") String msg,
@RequestParam(value="status") String status,
Model model) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
UserDetailsImpl principal = (UserDetailsImpl) authentication.getPrincipal();
model.addAttribute("username", principal.getUsername());
// @RequestParam 으로 전달받은 에러 메세지를 model 에 담는다
model.addAttribute("status", status);
model.addAttribute("msg", msg);
return "/error/accessDenied";
}
}
핸들러 구현 뿐만 아니라 핸들러에서 리다이렉트 시킨 URL 을 처리해주는 Controller 역시 세트로 필요합니다.
@RequestParam
을 통해 쿼리 파라미터로 넘어온 정보를 매개변수로 받고, Model 에 담아 화면에 전달합니다.
핸들러를 구현하지 않고 간단하게 하려면 configure() 에 아래의 코드만 추가하면 됩니다. 물론 이 요청을 처리하는 Controller 는 필요합니다.
http.exceptionHandling().accessDeniedPage("/accessDenied")
public class CustomLoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
String errorMessage = "";
if (exception instanceof BadCredentialsException) {
errorMessage = "비밀번호가 일치하지 않습니다";
} else if (exception instanceof UsernameNotFoundException) {
errorMessage = "등록되지 않은 사용자입니다";
}
setDefaultFailureUrl("/loginHome?exception=" + URLEncoder.encode(errorMessage, StandardCharsets.UTF_8));
super.onAuthenticationFailure(request, response, exception);
}
}
setDefaultFailureUrl()
을 지정하기 위해 SimpleUrlAuthenticationFailureHandler 를 상속받았습니다. setDefaultFailureUrl()
은 configure()
에서 .failureUrl()
과 동일한 동작을 합니다.
예외에 따른 예외 메세지를 지정하고 super.onAuthenticationFailure()
를 호출하면 지정된 url 로 리다이렉트됩니다.
전체 로직은 아래 github 에서 확인하실 수 있습니다.