항해99/개발일지

20220212 개발일지 #스프링 시큐리티 세션 토큰으로 보내기

paran21 2022. 2. 14. 00:13

시큐리티를 우선 세션으로 하기로 했는데, 시큐리티는 기본적으로 formLogin방식이었다.

프론트 코드를 함께 보면서 얘기를 해봤는데 form이 아닌 json으로 로그인 값을 받아야 할 것 같았다.

(axios는 서버 url 입력하는 부분이 있었는데, form은 그 부분이 없어서 어떻게 서버와 연결해야하는지 잘 모르겠다고 하셨다.)

그래서 시큐리티 설정의 formLogin을 disable로 하고 json으로 받는 방식으로 변경하였다.

json으로 받기 위해서 LoginDto를 새로만들고 UsernamePasswordAuthenticationFilter를 상속받아 custom 클래스를 만들었다.

LoginDto도 새로 만들어서 Filter가 가로챈 값을 넣어주었다.

public class CustomUsernamePasswordAuthenticationFilter
        extends UsernamePasswordAuthenticationFilter {

    private final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public Authentication attemptAuthentication(
            HttpServletRequest request, HttpServletResponse response
    ) throws AuthenticationException {
        UsernamePasswordAuthenticationToken authenticationToken;
        if(request.getContentType().equals(MimeTypeUtils.APPLICATION_JSON_VALUE)) {
            try {
            LoginDto loginDto = objectMapper.readValue(
                    request.getReader().lines().collect(Collectors.joining()),
                    LoginDto.class);
            authenticationToken = new UsernamePasswordAuthenticationToken(
                    loginDto.getUsername(),
                    loginDto.getPassword());
                }
            catch (IOException e) {
                e.printStackTrace();
                throw new AuthenticationServiceException("Request Content-Type(application/json) Parsing Error");
            }
            } else {
            //form으로 받는 경우
            String username = obtainUsername(request);
            String password = obtainPassword(request);
            authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
        }
        this.setDetails(request, authenticationToken);
        return this.getAuthenticationManager().authenticate(authenticationToken);
    }
}

그런데 formLogin을 비활성화시키니 연결되어 있던 .loginProcessingUrl이나 .failureUrl 등의 설정을 할 수 없었다.

그래서 SuccessHandler와 FailureHandler도 별도로 커스텀해야했다.

@RequiredArgsConstructor
@Configuration
@EnableWebSecurity // 스프링 Security 지원을 가능하게 함
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

...

	@Bean
    public LoginSuccessHandler loginSuccessHandler() {
        return new LoginSuccessHandler();
    }

    @Bean
    public LoginFailureHandler loginFailureHandler() {
        return new LoginFailureHandler();
    }


...

				.formLogin().disable();
                .addFilterAt(getAuthenticationFilter(), 
                				UsernamePasswordAuthenticationFilter.class);
                                
...

    protected CustomUsernamePasswordAuthenticationFilter getAuthenticationFilter() {
    CustomUsernamePasswordAuthenticationFilter authenticationFilter = new CustomUsernamePasswordAuthenticationFilter();
    try {
        authenticationFilter.setFilterProcessesUrl("/user/login");
        authenticationFilter.setAuthenticationManager(this.authenticationManagerBean());
        authenticationFilter.setAuthenticationSuccessHandler(loginSuccessHandler());
        authenticationFilter.setAuthenticationFailureHandler(loginFailureHandler());
    } catch (Exception e) {
        e.printStackTrace();
    }
    return authenticationFilter;
    }
public class LoginSuccessHandler implements AuthenticationSuccessHandler {

    private RequestCache requestCache = new HttpSessionRequestCache();
    private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

    @Override
    public void onAuthenticationSuccess(
            HttpServletRequest request,
            HttpServletResponse response,
            Authentication authentication) throws IOException, ServletException {
            
        //redirect
        resultRedirectStrategy(request, response, authentication);
        //로그인 실패한 에러 지우기
        clearAuthenticationAttributes(request);
    }


    protected void resultRedirectStrategy(
            HttpServletRequest request,
            HttpServletResponse response,
            Authentication authentication) throws IOException, ServletException {
        SavedRequest savedRequest = requestCache.getRequest(request,response);

        if(savedRequest != null) {
            //인증 권한이 필요한 페이지에 접근했을 경우
            //로그인 화면을 보기 전에 갔던 url로
            String targetUrl = savedRequest.getRedirectUrl();
            redirectStrategy.sendRedirect(request, response, targetUrl);
        } else {
            //로그인 화면에서 로그인시
            redirectStrategy.sendRedirect(request, response, "/boards");
        }
    }

    private void clearAuthenticationAttributes(HttpServletRequest request) {
        //세션을 받아옴
        HttpSession session = request.getSession(false);
        //세션이 null, 즉 에러가 없는 경우
        if (session == null) return;
        //WebAttributes.AUTHENTICATION_EXCEPTION 으로 정의된 세션을 지운다.
        session.removeAttribute(WebAttributes.AUTHENTICATION_EXCEPTION);
    }
}
public class LoginFailureHandler implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(
            HttpServletRequest request,
            HttpServletResponse response,
            AuthenticationException exception
    ) throws IOException, ServletException {

        String errorMsg = "";

        if(exception instanceof UsernameNotFoundException) {
            errorMsg = "존재하지 않는 아이디입니다.";
        } else if(exception instanceof BadCredentialsException) {
            errorMsg = "아이디 또는 비밀번호가 잘못 입력되었습니다.";
        }

        if(!StringUtils.isEmpty(errorMsg)) {
            request.setAttribute("errorMsg", errorMsg);
        }

        response.sendRedirect("/login?errorMsg=" + URLEncoder.encode(errorMsg, "UTF-8"));
    }
}

 

Handler부분은 전에 시도하다가 잘 안된 적이 있어서 걱정했는데 우선 잘 동작하는 것은 확인할 수 있었다.

json으로 받는게 이렇게 복잡해질지 몰랐어서, 이렇게 할거면 그냥 JWT토큰을 해보는게 나았을까 싶기도 했다.

하지만 우선 세션으로 시작한거 동작이 잘 되는지를 확인하고 싶었고, 프론트와 어떤 방식으로 시큐리티가 동작하는지 알고 싶었다. 

 


 

프론트와 백 모두 각각 서버에 올려서 잘 작동하는지 테스트해보기로 했다.

우선 회원가입 로그인 부분만 먼저 맞춰보기로 했다.

시큐리티 부분도 그렇고, 서버를 각각 올려서 맞추는 것도 처음이라 서로 어떤 식으로 설정을 해야하는지 햇갈렸다.

서버에서는 cors 설정을 할 때 프론트 서버를 열어줘야 하고, 프론트에서는 url에 서버 ip주소를 넣어주면 되는 것 같다.

 

배포 후에 get요청은 잘 들어가는 걸 확인했는데 post가 되지 않았다.

회원가입, 로그인 뿐만 아니라 스터디 모집글 작성도 안되는 걸 확인했다.

cors를 설정하는 방법이 여러가지 있는데, 스프링 시큐리티의 경우 별도의 설정을 추가해주어야 한다.

구글링에서 여러가지 방법이 나와서 시도해봤는데 모두 다 잘 안되었다.

문제가 시큐리티 쪽인지 아니면 cors 설정 자체인지 확인하기 위해서 시큐리티 쪽을 모두 주석으로 막고 다시 작동해보았다.

그런데도 계속 405 에러가 떠서 갑자기 cors 문제가 맞나 싶었다!

그래서 프론 분과 다시 확인해보니 프론트 쪽에서도 약간 오류가 있었다.

다행히 수정 후에 작동이 잘 되는 것을 확인했다.

시큐리티 설정을 포함하고 나서는 console에 cors 에러인 것을 확인했다.

(console은 내쪽에서도 똑같이 보이는 거였는데 프론트에서는 다르게 뜨는가? 싶어서 매번 확인해달라고 해서 죄송하다ㅜㅜ )

에러에 credential 부분이 있었는데 구글링하다 본 부분이라서 참고해 코드를 수정해주었다.

프론에서 axios에 with credential? 값을 추가해주고 서버에서도 allowcredentials를 해주니 여전히 cors에러는 났지만 credential 부분은 사라지고 에러가 훨씬 짧아졌다

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("http://localhost:8080",
                        "http://3.38.178.109/",
                        "http://~")
                .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH")
                .allowCredentials(true);
    }
}

그리고 시큐리티 설정을 추가해주니 잘 작동하는 것을 확인했다.

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http.csrf().disable()
        .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
        .authorizeRequests()
//                .antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                .requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
                // 회원 관리 처리 API 전부를 login 없이 허용
                .antMatchers("/user/**").permitAll()
                .antMatchers("/boards").permitAll()
                // 그 외 어떤 요청이든 '인증'
//                .anyRequest().permitAll()
                .anyRequest().authenticated()
                .and()
                .cors()
                .and()
// [로그인 기능]
                .formLogin().disable();

그런데 프론트에서 세션아이디를 데이터로 담아서 return해줄 수 있는지 물어보셨다.

세션아이디는 로그인 시에 쿠키에 담기는데(JSSESSIONID) 이상태로는 값을 따로 저장할 방법이 없어서 서버에서 따로 보내줘야 했다.

 

처음에 찾은 방법은 SuccessHandler에 JSON으로 값을 담아서 보내주는 방식이었다.

주석으로 처리한 부분인데, WebAuthenticationDetails에 세션아이디가 담긴다.

그리고 저 값을 JSON으로 보내주는 것인데, 이 경우 기존에 작성했던 redirect와 충돌이 있는 것 같았다.

redirect를 먼저 하면 그 이후 과정이 이루어지지 않는 것 같았고, 뒤로 넘겨도 json으로 값을 넘긴 뒤가 이루어지지 않는 것 같았다.

그리고 더 큰 문제는 세션아이디가 null이 담긴다는 것이다.

분명히 중간에 테스트 할 때 에러가 나도 담기는 걸 확인했던 것 같은데 코드를 여러 번 수정해도 null 값이 담겼다.

아마 세션아이디가 생성되는 시점과 관련된 부분인 것 같다.

successhandler를 이용한 부분은 JSON으로 값을 보내는 것만 참고해서 수정한 것이라 자신이 없어서 포기하고 다른 방법을 찾아봤다.

 

public class LoginSuccessHandler implements AuthenticationSuccessHandler {

    private RequestCache requestCache = new HttpSessionRequestCache();
    private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

    @Override
    public void onAuthenticationSuccess(
            HttpServletRequest request,
            HttpServletResponse response,
            Authentication authentication) throws IOException, ServletException {

//        WebAuthenticationDetails web = (WebAuthenticationDetails) authentication.getDetails();
//
//        MappingJackson2HttpMessageConverter jsonConverter = new MappingJackson2HttpMessageConverter();
//        MediaType jsonMimeType = MediaType.APPLICATION_JSON;
//
//        JSONResult jsonResult = JSONResult.success(web.getSessionId());
//        if (jsonConverter.canWrite(jsonResult.getClass(), jsonMimeType)) {
//            jsonConverter.write(jsonResult, jsonMimeType, new ServletServerHttpResponse(response));
//        }

        //redirect
        resultRedirectStrategy(request, response, authentication);
        //로그인 실패한 에러 지우기
        clearAuthenticationAttributes(request);
    }

스프링 시큐리티와 특히 리엑트를 검색하면 대부분 jwt가 나와서 방법을 바꿔야하나 고민이 되었다.

그리고 세션아이디가 쿠키에 그대로 찍히니까 확실히 보안에 문제가 있을 것 같았다.

그러다가 방법을 찾았는데 바로 Token을 이용하는 것이었다.

(사실 여기서도 결국 토큰으로 보내줄거면 암호화해서 보내주는 방식인 jwt가 보안적으로 더 안정적인 방법이라는 생각이 들었다.

이 부분은 더 공부하면서 확실히 확인해야할 것 같다.

이미 프로젝트가 어느정도 진행되서 그럼에도 jwt로 바꾸는게 나을지, 아니면 이번에는 세션방식으로 하고 jwt는 추가로 공부를 해서 다음 프로젝트 때 사용할지 고민이 된다.)

@Autowired
public UserController(UserService userService, AuthenticationManager authenticationManager) {
    this.userService = userService;
    this.authenticationManager = authenticationManager;
}

// 로그인
@PostMapping("/user/login")
public AuthenticationToken login(
        @RequestBody LoginDto loginDto,
        HttpSession session
) {
    String username = loginDto.getUsername();
    String password = loginDto.getPassword();

    UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password);
    Authentication authentication = authenticationManager.authenticate(token);
    SecurityContextHolder.getContext().setAuthentication(authentication);
    session.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,
            SecurityContextHolder.getContext());

    User user = userService.readUser(username);
    UserDetailsImpl userDetails = new UserDetailsImpl(user);
    return new AuthenticationToken(userDetails.getUsername(), userDetails.getAuthorities(), session.getId());
}

여기서 사용하는 authenticationManager는 WebSecurityConfig에 빈으로 등록해준다.

@RequiredArgsConstructor
@Configuration
@EnableWebSecurity // 스프링 Security 지원을 가능하게 함
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean
    @Override
    public AuthenticationManager authenticationManager() throws Exception {
        return super.authenticationManager();
    }

로그인 자체는 기존에 있던 UserDetailsImpl와 UserDetailsService를 통해서 진행되는데 컨트롤러에 추가한 부분을 통해 authenticationToken에 인증된 사용자 정보와 세션아이디를 담아서 return하는 방식인 것 같다.

(전에 기술매니저님께 물어볼 때 토큰 얘기를 하셔서 세션도 토큰이 있나? 했었는데 세션도 토큰이 있다! 이부분도 더찾아봐야할 것 같다.)

이런 방식으로 처리하니 로그인 json 방식을 구현하면서 작성했던 successhandler와 failuerhandler를 사용할 수 없게 되었다.

그렇다면 서버에서는 세션아이디만 발급해주고 이후 이동 경로 등의 처리 등은 프론트에서 진행을 하면 되는 것인지,

지금 로그인한 상태로도 스터디게시글 작성하기가 403에러가 나오는데 어디서 문제가 발생한 것인지(프론트도 일부만 연결해 놓은 상태다), 인증 여부도 프론트에서 관리하는 것인지 등등은 월요일에 테스트 해보면서 추가로 확인해야겠다.

 

스프링 시큐리티도 중간에 어떤 과정들을 통해서 인증이 이루어지는지 한번 더 봐야할 것 같고 토큰도 더 찾아봐야할 것 같다.

무리하게 새벽까지 작업하면서 결국 마무리는 했는데, 사실 처음부터 세션을 하지 말고 jwt토큰으로 했어야 했나, 라는 생각을 많이 했다.

쉽게 가려고 했다가 오히려 더 어렵게 간 것 같기도 하고, 쉽게 가려고 스스로 핑계를 댄 부분도 있지만 어쨌든 세션으로 진행도 해보고 싶었다.

하루종일 에러 처리하면서 너무 돌아왔나, 라는 생각도 들었는데 그래도 개발일지 쓰면서 정리해보니 나름 여러 시도를 해본 것 같고 배운 점도 많은 것 같다.

항해가 워낙 타이트한 과정이라서 공부하고 싶은 걸 다 못하고 가는 거 같은데 너무 조급해하지 말고 최소한 메모는 꼭 해놓고(잘 정리는 못해도 개발일지라고 쓰기!) 찾아봐야겠다.