개인프로젝트/기능프로그램_오늘뭐입지

20240525_React와 Spring Boot 연결(SpringSecurity Login)

일일일코_장민기 2024. 5. 25. 17:06
728x90

 

우선 알아야 할 것은 기존에 사용하던 SpringSecurity와의 차이이다.

1. 팀프로젝트

- 모놀리틱

- 프론트엔드는 jsp, 백엔드는 java를 사용하며 port 번호가 동일

 

2. MSA(1차)

- MSA

- 프론트엔드는 jsp, 백엔드는 java

--> SpringSecurity를 사용하는 모듈 자체가 동일

 

3. 프론트엔드와 백엔드의 분리

- 프론트엔드는 React

- 백엔드는 자바

--> 포트번호가 다름

--> SpringSecurity를 사용할 때 프론트엔드에서 인증요청이 백엔드로 넘어간 다음, 인증이 확인되면 프론트엔드로 다시 데이터를 브라우저에 뿌려줌

 

여기서 큰 차이가 발생하는 것은 1, 2번 vs 3번이다.

기존의 SpringSecurity 사용은 동일 모듈에서 사용했기 때문에 쿠키가 저장되는 것은 같은 포트 번호로 저장되었다.

ex) jsp도 java도 같은 모듈이기 때문에 동일한 포트번호를 사용

그런데 3번의 경우에는 프론트엔드와 백엔드가 분리되었기 때문에 쿠키가 다른 포트 번호로 저장되었다.

ex) react는 3000번, java는 8090번을 사용

 

이로인해 cors 등 다양한 문제가 발생했는데, 문제는 이걸 명시적으로 확인이 되지 않아서 문제 해결이 오래 걸렸다.

 

import { NavLink, useNavigate } from "react-router-dom";
import React, { useState } from "react";
import axios from "axios";

function LoginPage() {
    const [username, setUsername] = useState('');
    const [password, setPassword] = useState('');
    const navigate = useNavigate();

    const handleSubmit = async (event) => {
        event.preventDefault();
        axios.defaults.withCredentials = true; //*******************************************************
        try {
            const response = await axios.post('http://localhost:8888/login', {
                username: username,
                password: password
            }, {
                headers: {
                    'Content-Type': 'application/json'
                }
            });
            if (response.data.success) {
                navigate(response.data.frontendUrl);
            } else {
                console.log('로그인 실패');
                navigate(response.data.frontendUrl);
            }
        } catch (error) {
            console.error('Error:', error);
        }
    };

    return (
        <div>
            <form id="loginForm" onSubmit={handleSubmit}>
                <label>username: <input type="text" value={username} onChange={(e) => setUsername(e.target.value)} /></label>
                <label>password: <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} /></label>
                <span id="loginErrorField"></span><br/>
                <div>
                    <input type="submit" value="로그인" />
                </div>
            </form>
            <p>
                <NavLink to="/registerPage">회원가입 페이지</NavLink><br/>
                <NavLink to="/findUserInfo">회원 정보 찾기 페이지</NavLink><br/>
            </p>
        </div>
    );
}

export default LoginPage;

 

React의 로그인 페이지

- axios를 사용하여 백엔드의 스프링시큐리티 로그인인증 주소로 아이디와 비밀번호를 전송한다.

- 로그인에 성공하면 백엔드에서 지정해준 마이페이지 주소로 이동

- 로그인에 실패하면 백엔드에서 지정해준 로그인 페이지 주소로 이동

 

 

 

첫 번째로 문제가 되었던 부분

 

기존

@Override
public Authentication attemptAuthentication(final HttpServletRequest request, final HttpServletResponse response) {

    String username = request.getParameter("username");
    String password = request.getParameter("password");
    log.info("Attempting to authenticate user: " + username);
    UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
    return authenticationManager.authenticate(authRequest);
}

 

- 기존에는 jsp의 파라미터를 가져왔기 때문에 request.getParameter를 사용하면 바로 username과 password를 가져올 수 있었다.

 

변경

@Override
public Authentication attemptAuthentication(final HttpServletRequest request, final HttpServletResponse response) {

    Map<String, String> requestBody = null;
    try {
        requestBody = new ObjectMapper().readValue(request.getInputStream(), Map.class);
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
    String username = requestBody.get("username");
    String password = requestBody.get("password");
    log.info("Attempting to authenticate username: " + username);
    log.info("Attempting to authenticate password: " + password);
    UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
    return authenticationManager.authenticate(authRequest);
}

- React에서 보내준 데이터를 받고자 할 때는 이와 같이 변경해야 했다.

- React에서 서버로 전송하는 데이터는 HTTP 요청의 본문으로 전달되며 JSON 형식이기 때문에 ObjectMapper를 사용하여 Body에서 username과 password를 가져와야 한다.

 

 

두 번째로 문제가 되었던 부분

@GetMapping("/myPage")
public ResponseEntity<Object> myPage(Principal principal) {
    log.info("principal : {}", principal);
    MemberVO memberVO = clothService.checkLogined(principal.getName());
    if (memberVO != null) {
        UseMemberDataDTO useMemberDataDTO = new UseMemberDataDTO();
        BeanUtils.copyProperties(memberVO, useMemberDataDTO);
        return ResponseEntity.ok(useMemberDataDTO);
    }
    String frontendUrl = "http://localhost:3000/loginPage"; 
    return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(frontendUrl);
}

 

여기서 principal이 null로 나오는 문제가 발생했다.

당연히 principal을 만들지 못한다고 생각하여 기존에 만들었던 LoginFilter와 JWTFilter를 계속 고쳐봤지만 문제가 해결되지 않았다.

 

 

 

특히 황당했던 부분은 쿠키를 만들었는데 안 만들어지는(?) 문제였다.

 

LoginFilter

@Override
protected void successfulAuthentication(final HttpServletRequest request, final HttpServletResponse response, final FilterChain chain, Authentication authResult) throws IOException, ServletException {

    SecurityUser securityUser = (SecurityUser) authResult.getPrincipal();
    String username = securityUser.getUsername();

    Collection<? extends GrantedAuthority> authorities = securityUser.getAuthorities();
    Iterator<? extends GrantedAuthority> authoritiesIterator = authorities.iterator();
    GrantedAuthority grantedAuthority = authoritiesIterator.next();
    String role = grantedAuthority.getAuthority();

    String token = jjwtUtil.createJwt(username, role, expiredMs);

    Cookie authCookie = new Cookie("AuthToken", token);
    authCookie.setMaxAge(360000);
    authCookie.setPath("/");
    authCookie.setHttpOnly(true);
    if (request.isSecure()) {
        authCookie.setSecure(true);
    }
    response.addCookie(authCookie);
    response.sendRedirect("/myPage");
}

 

이 파트에서 

Cookie authCookie = new Cookie("AuthToken", token);
authCookie.setMaxAge(360000);
authCookie.setPath("/");
authCookie.setHttpOnly(true);
if (request.isSecure()) {
    authCookie.setSecure(true);
}
response.addCookie(authCookie);

 

이 코드를 통해 쿠키를 만들었고, 이 과정 자체에는 아무런 문제가 없다.

 

또한 



JWTFilter

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

    String token = null;
    Cookie[] cookies = request.getCookies();
    if (cookies != null) {
        for (Cookie cookie : cookies) {
            if ("AuthToken".equals(cookie.getName())) {
                token = cookie.getValue();
                break;
            }
        }
    }
    log.info("JJWT Token: {}", token);

    if (token != null && jjwtUtil.validateToken(token)) {
        String username = jjwtUtil.getUsername(token);
        String country = jjwtUtil.getPassword(token);
        String area = jjwtUtil.getRole(token);

        MemberDTO memberDTO = new MemberDTO();
            memberDTO.setUsername(username);
            memberDTO.setCountry(country);
            memberDTO.setArea(area);

        SecurityUser springSecurityUser = new SecurityUser(memberDTO);
        Authentication authentication = new UsernamePasswordAuthenticationToken(springSecurityUser, null, springSecurityUser.getAuthorities());
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }

    filterChain.doFilter(request, response);
}

 

이 파트에서

String token = null;
Cookie[] cookies = request.getCookies();
if (cookies != null) {
    for (Cookie cookie : cookies) {
        if ("AuthToken".equals(cookie.getName())) {
            token = cookie.getValue();
            break;
        }
    }
}
log.info("JJWT Token: {}", token);

 

이 코드를 통해 쿠키를 찾았고, 이 코드 자체에는 아무런 문제가 없다.

애초에 기존 프로젝트에서 잘만 작동하던 코드였다.

 

그런데 JWTFilter에서 token을 찾지 못하는 현상이 발생했고, Controller에서도 Principal이 null로 나오는 현상이 발생했다.

또한 크롬 개발자 도구에서 애플리케이션 탭의 쿠키를 확인했을 때 JWT가 없는 것도 확인할 수 있었다.

 

 

 

 

하지만 알고보니 이런 현상은 모두 CORS 때문에 발생한 현상이었다.

CORS는 MSA로 프로젝트를 짜면서 발생한 적이 있었지만, CORS 문제라고 명시적으로 표시되었는데 이번에는 CORS 문제라고 어디에도 표시되지 않았다. 애초에 CORS 문제는 생각하고 있었기 때문에 백엔드의 Configuration 설정은 이미 해두어서 더 찾기 어려웠던 것 같다.

package org.example.util;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfiguration implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("http://localhost:3000")
                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                .allowCredentials(true);
    }
}

 

 

 

그래도 CORS 문제라고 의심이 든 계기는 네트워크 응답헤더를 확인했을 때 Set-Cookie를 확인했을 때였다.

 

백엔드 어디에서도, 애플리케이션 탭에서도 확인이 안 되던 쿠키가 네트워크 응답헤더에는 있었다.

이 과정을 거치며 CORS 문제를 재검토하게 되었고, 프론트에서도 백엔드처럼 credential 설정이 필요하다는 것을 알게 되었다.

const handleSubmit = async (event) => {
    event.preventDefault();
    axios.defaults.withCredentials = true; //*******************************************************

 

 

********** 의문인 부분**********

# 쿠키가 백엔드에서 생성되었고 응답헤더에 넣었다. 또한 쿠키는 클라이언트에서 관리한다. 그러면 이번 문제 상황에서는 어디에 저장이 되었던 것인가?

-> 백엔드 8888포트 / 프론트 3000포트

1. 백엔드 8888포트에 저장이 되진 않았을 것이다.

-> 저장되었다면 백엔드에서 쿠키 조회를 했을 때 출력됐었겠지...

2. 프론트 3000번 포트에 저장이 되었는가?

-> 그러면 왜 localhost:3000 애플리케이션 탭의 쿠키에서 확인이 되지 않는거지?

3. 그러면 8888번 포트 클라이언트에 저장이 된건가?

-> 마찬가지로 localhost:8888 애플리케이션 탭의 쿠키에서 확인이 되지 않는다...

결론: 저장된 쿠키는 어디에 있었길래 확인이 어려웠던 걸까?

 

상황도 난해하고, 찾아도 나오질 않는다...보충해야 할 부분...

 

 

참고 자료

https://velog.io/@youjung/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%99%80-%EB%B0%B1%EC%97%94%EB%93%9C-%EB%B6%84%EB%A6%AC%ED%96%88%EC%9D%84-%EB%95%8C-%EC%BF%A0%ED%82%A4-%EA%B3%B5%EC%9C%A0-%EC%95%88%EB%90%A0-%EB%95%8C

 

프론트와 백엔드 분리했을 때 쿠키 공유 안되는 이유 (feat. Credentials 옵션)

문제 상황 [ 개발 환경 ] 프론트엔드: http://localhost:8081 백엔드: http://localhost:8080 혼자서 프론트엔드와 백엔드를 분리한 상황에서 로그인을 개발하고 있는데, 세션에서 유저 정보를 제대로 가져오

velog.io