[Spring Security] 세션 기반 로그인

HJ·2023년 8월 15일
0

Spring Security

목록 보기
4/9
post-thumbnail

참고 : https://mangkyu.tistory.com/77
참고 : https://anjoliena.tistory.com/108
참고 : https://velog.io/@dailylifecoding/spring-security-logout-feature


WebSecurityConfig


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 의 내용은 주석으로 달아놓았기 때문에 설명은 생략하도록 하겠습니다.




AuthenticationFilter


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 에게 전달합니다.




LoginSuccessHandler


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() 과 동일한 동작을 합니다.




AuthenticationProvider


@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 에서 조회하는 것은 UserDetailsServiceloadUserByUsername() 메서드를 이용하며, 조회한 유저의 비밀번호와 일치한다면 인증이 완료된 UsernamePasswordAuthenticationToken 을 생성해 반환합니다.




UserDetailsService


@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 에게 전달되고, 인증이 완료되면 인증된 UsernamePasswordAuthenticationTokenAuthenticationFilter 에 전달합니다.

AuthenticationFilter 에서는 LoginSuccessHandler 로 전달하고, LoginSuccessHandler 로 넘어온 Authentication 객체를 SecurityContextHolder 에 저장하면 인증 과정이 끝나게 됩니다.




Logout


    @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:authorizesec:authentication 를 사용하여 로그인 정보 가져와 권한에 따라 페이지를 다르게 보이게 한다던지, 화면에 유저의 이름을 띄운다던지 작업을 할 수 있다 정도만 보고 넘어가도록 하겠습니다.




AccessDenied


[ AccessDeniedHandler ]

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 로 사용했습니다.



[ ErrorController ]

@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")




LoginFailureHandler


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 에서 확인하실 수 있습니다.

0개의 댓글