스프링

JWT_사용자 정의 로그인

infobox503 2025. 1. 31. 10:24

<요약>

  • 목적
    • JWT 로그인 인증을 REST API 형식으로 구축한다.
  • 과정
    1. 프론트 : 로그인 요청
    2. 백엔드 : 로그인 인증 및 JWT 쿠키 전달
    3. 프론트 : 각 요청 헤더에 JWT를 담아서 백엔드에게 API 요청
      • (전달받은 JWT 쿠키는 자동으로 클라이언트에 저장됨)
    4. 백엔드 : JwtFilter를 통해 요청 헤더의 JWT를 검증. 그 후, 인증된 사용자에게는 API 제공

 

<코드 설명>

1. SecurityConfig

  • 스프링 시큐리티
  • 각 요청에 필터를 적용 할 수 있음
    • (검증된 요청자에게만 API 제공)
  • 목적
    • 각 API 요청마다 JwtAuthenticationFilter를 적용 시키는 것
    • (JwtAuthenticationFilter는 요청 헤더에 있는 JWT 토큰을 검증함)
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final CustomUserDetailsSuccessHandler customUserDetailsSuccessHandler;


    @Autowired
    public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter,
                          CustomUserDetailsSuccessHandler customUserDetailsSuccessHandler){
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
        this.customUserDetailsSuccessHandler = customUserDetailsSuccessHandler;

    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .cors(cors -> cors
                        .configurationSource(CorsConfig.corsConfigurationSource())) // CORS 허용
                .csrf(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(request -> request
                        .requestMatchers(
                                new AntPathRequestMatcher("/"),
                                new AntPathRequestMatcher("/api/auth/**"),
                                new AntPathRequestMatcher("/signup")
                        ).permitAll()
                        .requestMatchers("/security/user").hasRole("USER")
                        .requestMatchers("/security/admin").hasRole("ADMIN")
                        .anyRequest().authenticated()
                )
//                .formLogin(form -> form
//                        .loginPage("/login")
//                        .defaultSuccessUrl("/", true)
//                        .failureUrl("/login?error=true")
//                        .usernameParameter("email")
//                        .successHandler(customUserDetailsSuccessHandler)
//                        .permitAll()
//                )
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }


    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

 

 

2. JwtAuthenticationFilter

  • 목적
    • 각 API 요청마다 담긴 헤더의 JWT 토큰 검증
  • 과정
    1. 요청받은 헤더에서 JWT 토큰을 추출한다.
      • resolveToken 함수를 통해 헤더에 있는 JWT 토큰 추출
    2. JWT 토큰을 검증한다.
      • JwtTokenProvider 객체를 통해 JWT 토큰을 검증하는 로직을 적용한다.
    3. 검증된 토큰이면 Authentication에 사용자 정보를 저장한다.
      • Authentication에 사용자 정보를 담아서 인증된 사용자임을 증명한다.
      • (다음에 진행되는 필터는 Authentication에 담긴 사용자 정보를 보고 인증된 사용자임을 확인 할 수 있다) - QnA에서 자세히 진행
    4. 그 다음 필터를 진행시킨다.
      • JwtAuthenticationFilter의 다음 필터를 진행시킨다.
      • Authentication에 사용자 정보를 담아 놓았기 때문에 해당 사용자는 인증을 받을 수 있다.
@Slf4j
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;

    @Autowired
    public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider){
        this.jwtTokenProvider = jwtTokenProvider;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = resolveToken(request);
        
        //토큰 유효 및 만료 기간 검증
        if(token != null && jwtTokenProvider.isValid(token)){
            this.setAuthentication(token);
        }

        log.info("token : {}", token);
        filterChain.doFilter(request, response);
    }

    private void setAuthentication(String token){
        Authentication authentication = jwtTokenProvider.getAuthentication(token);
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }

    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

 

 

 

3. JwtTokenProvider

  • 목적
    • JWT 토큰 생성 및 검증
  • 방법
    • jsonwebtoken(12.6)은 jwt를 생성하고 검증할 수 있는 라이브러리를 제공한다.
@Getter
@Slf4j
@Component
public class JwtTokenProvider {
    @Value("${jwt.key}")
    private String KEY;
    private SecretKey SECRET_KEY;
    private final long ACCESS_EXPIRE_TIME = 1000 * 60 * 30L;
    private final long REFRESH_EXPIRE_TIME = 1000 * 60 * 60L * 24 * 7;
    private final String KEY_ROLE = "role";
    private final String HEADER_TYPE = "typ";
    private final String HEADER_JWT_TYPE = "JWT";
    private final String ALGORITHM = "HmacSHA256";
    private final String JWT_COOKIE_NAME = "JwtCookie";

    @PostConstruct
    private void setSecretKey() {
        if(KEY.length() < 32){
            log.info("JWT 키의 문자열 길이는 32 이상이어야 합니다.");
            throw new IllegalArgumentException("The secret key must be at least 32 bytes long.");
        }

        SECRET_KEY = Keys.hmacShaKeyFor(KEY.getBytes());
    }

    public Cookie createJwtCookie(String username, Role role){
        String jwtToken = createToken(username, role);
        Cookie cookie = new Cookie(JWT_COOKIE_NAME, jwtToken);
        cookie.setHttpOnly(false);  // 클라이언트에서 JavaScript로 접근 불가
//        cookie.setSecure(true);    // HTTPS에서만 전송
        cookie.setPath("/");       // 모든 경로에서 쿠키에 접근 가능
        cookie.setMaxAge((int)ACCESS_EXPIRE_TIME);

        return cookie;
    }

    public String createToken(String username, Role role) {
        Date beginDate = new Date();
        Date endDate = new Date(beginDate.getTime() + ACCESS_EXPIRE_TIME);

        return Jwts.builder()
                .header()
                .add(HEADER_TYPE, HEADER_JWT_TYPE)
                .and()
                .subject(username)
                .claim(KEY_ROLE, "ROLE_"+role)
                .issuedAt(beginDate)
                .expiration(endDate)
                .signWith(new SecretKeySpec(SECRET_KEY.getEncoded(), ALGORITHM))
                .compact();
    }

    public String createToken(Authentication authentication) {
        Date beginDate = new Date();
        Date endDate = new Date(beginDate.getTime() + ACCESS_EXPIRE_TIME);

        //authentication.getName() is username
        log.info("authentication.getName() : {}", authentication.getName());

        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining());

        return Jwts.builder()
                .header()
                .add(HEADER_TYPE, HEADER_JWT_TYPE)
                .and()
                .subject(authentication.getName())
                .claim(KEY_ROLE, authorities)
                .issuedAt(beginDate)
                .expiration(endDate)
                .signWith(new SecretKeySpec(SECRET_KEY.getEncoded(), ALGORITHM))
                .compact();
    }

    public boolean isValid(String token) {

        Claims payload = tokenToClaims(token);

        try{
            if(payload != null && payload.getExpiration().after(new Date())){
                return true;
            }
        }catch(NullPointerException e){
            log.error("payload || getExpiration() is null");
            return false;
        }

        return false;
    }

		// token을 파싱하는 과정에서 오류가 나지 않으면 검증이 된 것
    public boolean validateToken(String token) {
        try {

            Claims payload = Jwts.parser()
                    .verifyWith(SECRET_KEY)
                    .build()
                    .parseSignedClaims(token)
                    .getPayload();

            return true;
        } catch (JwtException | IllegalArgumentException e) {
            return false;
        }
    }

    public Authentication getAuthentication(String token) {
        Claims claims = tokenToClaims(token);
        if(claims == null){
            return null;
        }
        List<SimpleGrantedAuthority> authorities = getAuthorities(claims);
        log.info("authorities : {}", authorities);

        User principal = new User(claims.getSubject(), "",authorities);
        return new UsernamePasswordAuthenticationToken(principal, token, authorities);
    }

    private List<SimpleGrantedAuthority> getAuthorities(Claims claims) {
        return Collections.singletonList(new SimpleGrantedAuthority(
                claims.get(KEY_ROLE).toString()));
    }

    private Claims tokenToClaims(String token){
        try {
            return Jwts.parser()
                    .verifyWith(SECRET_KEY)
                    .build()
                    .parseSignedClaims(token)
                    .getPayload();

        } catch (JwtException | IllegalArgumentException e) {
            return null;
        }
    }



}

 

 

 

4. AuthController

  • 목적
    • 로그인 요청 검증 및 Jwt 토큰을 쿠키로 발급
  • 방법
    • 로그인 요청을 받는다.
    • DB에 회원이 있는지 검사한다.
    • 검증된 회원은 Jwt 토큰을 쿠키 형태로 반환한다.
@Slf4j
@Controller
@RequestMapping("/api/auth")
public class AuthController {

    private final AuthService authService;
    private final JwtTokenProvider jwtTokenProvider;

    @Autowired
    public AuthController(AuthService authService, JwtTokenProvider jwtTokenProvider) {
        this.authService = authService;
        this.jwtTokenProvider = jwtTokenProvider;
    }

    // login 검증 및 JWT 토큰 발급
    @PostMapping("/login")
    public ResponseEntity<String> loginRequest(@RequestBody UserDto userDto, HttpServletResponse response) {
        log.info("userDto : {}", userDto);

        userDto = authService.findByUser(userDto.getEmail());
        if(userDto == null){
            return ResponseEntity.badRequest().body("조회되지 않는 회원입니다.");
        }

        response.addCookie(jwtTokenProvider.createJwtCookie(userDto.getEmail(), userDto.getRole()));
        return ResponseEntity.ok("Login successful");
    }


}

 

 

 

<코드 설명 - 기타 등등>

1. CorsConfig

  • CORS(Cross Origin Resource Sharing)
    • CORS 정책
      • 서로 다른 Origin으로의 접근을 막음
      • Origin : Protocal + Host + Port
  • 목적
    • 프론트와 연동 과정에서 발생하는 CORS 문제 해결
  • 방법
    • 스프링 시큐리티는 CORS 정책 수정이 가능하다.
    • 즉, Front Origin 주소에 대한 접근을 허용하면 된다.
  • 세팅 방법
    • 접근 허용 Origin 지정
      • CorsConfiguration.setAllowedOrigins()
    • 허용 HTTP Method 지정
      • CorsConfiguration.setAllowedMethods()
    • 요청 헤더 허용
      • CorsConfiguration.setAllowedHeaders
    • Credentials 허용
      • CorsConfiguration.setAllowCredentials
      • Credentials?
        • 인증을 위해 사용되는 정보
          • (쿠키 및 Authorization 헤더 등)
        • CORS 정책에서는 Credentials의 정보가 서로 다른 Origin에서 오고 가는 것을 금지한다.
        • 따라서 쿠키를 자동으로 Request받고 싶다면 양쪽 Origin에서 Credentials의 사용을 허가해야한다.
          • Csrf 공격에 취약해지므로 Credentials의 허가는 신중히 결정해야한다.
            • (사용자가 쿠키를 지닌 상태에서 악의적인 Request가 이뤄진다면 민감한 Response 정보가 탈취될 수 있음)
          • 쿠키를 받고 싶다면 Credentials 외에, 프론트가 보내는 Request에 직접적으로 쿠키 내용을 저장하는 방법도 있다.
@Configuration
public class CorsConfig {


    public static CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();

        //리소스를 허용할 URL 지정
        ArrayList<String> allowedOriginPatterns = new ArrayList<>();
        allowedOriginPatterns.add("http://localhost:3000");
        allowedOriginPatterns.add("http://127.0.0.1:3000");
        configuration.setAllowedOrigins(allowedOriginPatterns);

        //허용하는 HTTP METHOD 지정
        ArrayList<String> allowedHttpMethods = new ArrayList<>();
        allowedHttpMethods.add("GET");
        allowedHttpMethods.add("POST");
        allowedHttpMethods.add("PUT");
        allowedHttpMethods.add("DELETE");
        configuration.setAllowedMethods(allowedHttpMethods);

        configuration.setAllowedHeaders(Collections.singletonList("*"));
//        configuration.setAllowedHeaders(List.of(HttpHeaders.AUTHORIZATION, HttpHeaders.CONTENT_TYPE));

        //인증, 인가를 위한 credentials 를 TRUE로 설정
        configuration.setAllowCredentials(true);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);

        return source;
    }
}

 

 

 

SecurityConfig에 적용

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .cors(cors -> cors
                        .configurationSource(CorsConfig.corsConfigurationSource())) // CORS 허용
                ...
  
        return http.build();
    }
}

 

 

 

2. UsernamePasswordAuthenticationFilter

  • UsernamePasswordAuthenticationFilter
    • /login 경로로 오는 요청(로그인 요청)에 대해서만 실행
    • 인증 방법
      • AuthenticatoinManager → ProviderManager →AuthenticationProvider → UserDetailsService
      • AuthenticatoinManager
        • ProviderManager의 부모 객체
      • ProviderManager
        • 여러 개의 AuthenticationProvider 객체를 관리
        • 각 인증 방식(OAuth2, 사용자 정의 로그인 폼, ..)에 맞는 AuthenticatoinProvider를 수행시킴
      • AuthenticationProvider
        • 로그인 인증 수행
        • UserDetailsService.loadByUsername(username)으로 DB에서 실제 사용자 정보를 가져옴
        • 실제 사용자 정보와 Authentication에 있는 사용자 정보를 매칭시킴
        • 서로 정보가 동일하면 실제 사용자가 맞으므로, 아래 정보 반환
return UsernamePasswordAuthenticationToken(username, password, userDetails.getAuthorities());

----------
//UsernamePasswordAuthenticationToken
//해당 토큰은 Authentication을 부모 타임으로 가짐
//해당 토큰을 이용하면 Authentication을 사용함과 동시에, 아래와 같이 인증된 사용자로 등록 가능
public UsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
    super(authorities);
    this.principal = principal;
    this.credentials = credentials;
    super.setAuthenticated(true);
}

 

(출처 : https://assu10.github.io/dev/2023/11/25/springsecurity-authrorization-1/)

/**
 * `AuthenticationProvider` 계약을 구현하는 클래스
 */
@Component  // 이 형식의 인스턴스를 컨텍스트에 포함시킴
public class CustomAuthenticationProvider implements AuthenticationProvider {
  private final UserDetailsService userDetailsService;
  private final PasswordEncoder passwordEncoder;

  public CustomAuthenticationProvider(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) {
    this.userDetailsService = userDetailsService;
    this.passwordEncoder = passwordEncoder;
  }

  /**
   * 인증 논리 구현
   */
  @Override
  public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    String username = authentication.getName();
    String password = authentication.getCredentials().toString();

    // UserDetails 를 가져오기 위해 UserDetailsService 구현 이용
    // 사용자가 존재하지 않으면
    //     loadUserByUsername() 는 AuthenticationException 예외 발생시킴
    //     인증 프로세스가 중단되고 HTTP 필터는 401 리턴
    UserDetails userDetails = userDetailsService.loadUserByUsername(username);

    // 사용자가 존재하면 matches() 로 암호 확인
    if (passwordEncoder.matches(password, userDetails.getPassword())) {
      // 암호가 일치하면 AuthenticationProvider 는
      // 필요한 세부 정보가 담긴 Authentication 계약의 구현을 '인증됨' 으로 표시한 후 반환함
      return new UsernamePasswordAuthenticationToken(username, password, userDetails.getAuthorities());
    } else {
      // 암호가 일치하지 않으면 AuthenticationException 형식의 예외 발생
      throw new BadCredentialsException("BadCredentialsException...!!");
    }
  }

  /**
   * AuthenticationProvider 가 어떤 종류의 Authentication 인터페이스를 지원할 지 결정
   * 이는 authenticate() 메서드의 매개 변수로 어떤 형식이 제공될지에 따라서 달라짐
   * <p>
   * AuthenticationFilter 수준에서 아무것도 맞춤 구성하지 않으면(지금은 이런 케이스) UsernamePasswordAuthenticationToken 클래스가 형식을 정의함
   */
  @Override
  public boolean supports(Class<?> authentication) {
    // UsernamePasswordAuthenticationToken 는 Authentication 인터페이스의 한 구현이며,
    // 사용자 이름과 암호를 이용하는 표준 인증 요청을 나타냄
    return authentication.equals(UsernamePasswordAuthenticationToken.class);
  }
}

 

  • UserDetailsService
    • AuthenticationProvider가 실제 DB에서 사용자 정보를 가져오기 위해 사용하는 객체
    • Authentication 정보를 가지고 DB에서 실제 사용자 정보를 가져오면 됨
    • AuthenticationProvider는 해당 실제 사용자 정보를 통해 Authentication의 유저가 실제 있는지 확인 가능
    • (따라서, Authentication의 username은 고유 식별자여야 함. 해당 username을 통해 DB에서 값을 조회하는 구조이기 때문에, 굳이 username이 아니더라도 Authentication의 username칸은 고유 식별자 값이 올 수 있도록 해야함)
@Service
@Slf4j
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Autowired
    public CustomUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        System.out.println("email : " + email);
        log.info("email : {}", email);
        Users users = userRepository.findByEmail(email);
        if(users == null){
            log.error("user is null");
            throw new UsernameNotFoundException("User not found with email: " + email);
        }

        //******** 수정 변경 사항 1.10 *************
        // getUsername -> getEmail로 수정바람
        UserDetailsDto userDetailsDto = new UserDetailsDto(userToUserDto(users));
        return new Principal(userDetailsDto);
    }


    private UserDto userToUserDto(Users users){

        return UserDto.builder()
                .id(users.getId())
                .email(users.getEmail())
                .password(users.getPassword())
                .username(users.getUsername())
                .phoneNumber(users.getPhoneNumber())
                .role(users.getRole())
                .birthday(users.getBirthday())
                .build();
    }
}

 

 

<QnA>

 

1) Authentication으로 사용자 정보를 검증하는 과정?

  • 사용자 정보는 2개로 분리된다.
    • DB 정보, 권한
    1. DB 정보
      • 요청받은 Authentication의 정보가 DB User로 있는지 확인한다.
      • UsernamePasswordAuthenticationFilter 또는 jwtAuthenticationFilter가 그 역할을 담당한다.
      • 위 필터들은 DB에서 사용자를 조회 후, SecurityContext에 저장함으로써 인증된 사용자임을 증명한다.
    2. 권한
      • DB 정보로 조회된 사용자 권한(ROLE_USER, ROLE_ADMIN, …)이 해당 API에 접근 가능한지 확인한다.
      • AuthorizationFilter는 접근 가능한 권한을 가졌는지 확인한다.
public class AuthorizationFilter extends GenericFilterBean {
    ...

    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws ServletException, IOException {
        HttpServletRequest request = (HttpServletRequest)servletRequest;
        HttpServletResponse response = (HttpServletResponse)servletResponse;
        ...

            try {
                AuthorizationResult result = this.authorizationManager.authorize(this::getAuthentication, request);
                this.eventPublisher.publishAuthorizationEvent(this::getAuthentication, request, result);
                if (result != null && !result.isGranted()) {
                    throw new AuthorizationDeniedException("Access Denied", result);
                }

                chain.doFilter(request, response);
            } finally {
                request.removeAttribute(alreadyFilteredAttributeName);
            }

        }
    }
}

 

 

2) jwtAuthenticationFilter는 왜 UsernamePasswordAuthenticationFilter 이전에 실행되어야 하나?

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

 

  • 추정
    • JwtAuthenticationFilter는 UsernamePasswordAuthenticationFilter의 역할을 대신하기 때문이다.
      • UsernamePasswordAuthenticationFilter
        • UsernamePasswordAuthenticationFilter는 주어진 Authentication의 정보로 DB에 있는사용자 정보를 조회한다.
        • 조회된 사용자는 Authentication 형태로 SecurityContext에 저장된다.
        • 즉, UsernamePasswordAuthenticationFilter는 SecurityContext에 실제 사용자 정보를 넣는 역할을 한다.
      • JwtAuthenticationFilter
        • JwtAuthenticationFilter는 UsernamePasswordAuthenticationFilter와 역할이 같다.
        • JwtAuthenticationFilter는 사용자로부터 JWT 토큰을 받는다.
        • 해당 JWT 토큰을 통해 실제 사용자 정보를 DB에서 가져온다.
        • 조회된 사용자는 Authentication 형태로 SecurityContext에 저장된다.
        • 즉, JwtAuthenticationFilter는 SecurityContext에 실제 사용자 정보를 넣는 역할을 한다.
      • ⇒ /login 요청에 대해서는 UsernamePasswordAuthenticationFilter가 SecurityContext에 DB 사용자를 넣는다.
      • ⇒ JWT 토큰이 있을때는 JwtAuthenticationFilter가 SecurityContext에 DB 사용자를 넣는다.
      • JwtAuthenticationFilter 또는 UsernamePasswordAuthenticationFilter를 통해 만들어진 SecurityContext의 Authentication의 DB 사용자 정보를 토대로 나머지 필터링이 진행가능해진다.

※ 용어

  • SecurityContextHolder
    • SecurityContext 객체를 지님
  • SecurityContext
    • Authentication 객체를 지님
  • ⇒ SecurityContextHolder를 호출하여 현재 Authentication 정보를 확인 가능

 

3. Front는 JWT 쿠키를 어떻게 사용해야하나?

  • 방법
    • CORS 정책 수정
      • Credentials(인증 정보)가 오고가는 것을 허용
        • (쿠키 사용 및 Authorization 헤더 사용을 위함)
    • Cookie에서 JWT 추출
      • Cookies.get(JwtCookie)
    • JWT를 Authorization 헤더에 담아서 요청 보냄
const response = await fetch(`${BASE_URL}${LOGIN_ENDPOINT}`, {
    method: 'GET',
    headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${jwtCookie}`
    },
    //include 시, 쿠키가 자동으로 request에 포함됨
    //하지만 이미 헤더에 jwt를 담았으므로 필요없음
    //credentials: 'include'
});

 

예시 코드

import React from 'react';
import styles from '../css/login.module.css'
import Cookies from 'js-cookie';


const Login = () => {
    return (
        <div>
            <LoginForm/>
        </div>
    );
};

const LoginForm = () => {
    const BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8080';
    const LOGIN_ENDPOINT = '/security/user';
    const JwtCookie = 'JwtCookie';

    const getJwtCookie = () => {
        return Cookies.get(JwtCookie);
    };

    // 폼 제출 처리 함수
    const handleSubmit = async (e) => {
        e.preventDefault(); // 기본 폼 제출 방지

        // 폼 데이터를 수집
        const formData = new FormData(e.target);
        const json = Object.fromEntries(formData);
        const jwtCookie = getJwtCookie();
        try {
            // POST 요청 보내기
            const response = await fetch(`${BASE_URL}${LOGIN_ENDPOINT}`, {
                method: 'GET',
                headers: {
                    'Content-Type': 'application/json',
                    'Authorization': `Bearer ${jwtCookie}`
                },
            });

            if (response.ok) {
                alert('조회 성공' + response.text());
            } else {
                alert(jwtCookie);
                alert('조회 실패');
            }
        } catch (err) {
            console.error(err);
            alert('오류가 발생했습니다. 나중에 다시 시도해주세요.');
        }
    };
    return (
        <>
            <form onSubmit={handleSubmit} className={styles['login-form']}>
                <div className={styles.heading}>로그인</div>

                <label htmlFor="email" className={styles.label}>이메일</label>
                <input
                    type="email"
                    id="email"
                    name="email"
                    className={styles.input}
                    required
                    placeholder="이메일을 입력하세요"
                />

                <label htmlFor="password" className={styles.label}>비밀번호</label>
                <input
                    type="password"
                    id="password"
                    name="password"
                    className={styles.input}
                    required
                    placeholder="비밀번호를 입력하세요"
                />

                <input type="submit" value="로그인" className={styles['submit-button']}/>
            </form>
        </>
    );
};

export default Login;

 

 

 

4) JWT 인증 방식 수정계획

  • 요약
    • 현재 방식
      • 백엔드 → 프론트
        • 쿠키 전달
        • 쿠키 : 쿠키 읽기 가능, Credentials 비허용
      • 프론트 → 백엔드
        • Request Header에 jwt 담아서 전달
      • 백엔드
        • 헤더 파싱하여, jwt 검증
    • 적절한 방법인가?
      • No
      • Xss 공격에 취약함
    • 대응 방안
      • LocalStorage : Access Token
        • 해당 토큰은 생명 주기를 30분 미만으로 설정
      • Cookie : Refresh Token
        • 해당 토큰은 생명 주기를 12시간으로 설정
        • 쿠키 : 읽기 불가, Credentials 허용
  • private variable
    • 결론
      • 로그인 등의 상태 유지가 어려워서 private variable을 사용하지 않음
    • 시나리오
      • 백엔드 → 프론트
        • JWT 전달
      • 프론트
        • private variable로 jwt 저장
    • 장점
      • XSS, CORS 공격에 상대적으로 안전
        • 새로고침, 페이지 이동에 의해 변수에 있는 JWT 값이 사라지기 때문에 JWT 탈취가 어려움
    • 단점
      • JWT로 상태 유지가 어려움
        • 새로고침, 페이지 이동에 의해 JWT가 사라지므로, 로그인 유지 등이 어려움
  • SessionStorage
  • LocalStorage
    • JWT를 LocalStorage에 저장
    • 장점
      • CSRF 공격에 비교적 안전
        • 쿠키처럼 request 요청에 자동으로 JWT가 포함되지 않으므로 CSRF 공격에 안전함
          • (쿠키는 Credentials를 프론트, 백엔드 둘 다에서 허용 시, 쿠키가 자동으로 request에 포함됨)
    • 단점
      • XSS 공격에 취약함
        • 사용자가 어느 웹페이지에 있든 LocalStorage에 내용은 보존된다.
        • 따라서, 공격자가 어떻게든 XSS 공격에 성공하기만 하면 LocalStorage에 저장된 내용을 탈취 가능
  • 쿠키
    • JWT를 쿠키에 저장
    • 장점
      • XSS 공격에 방어 가능
        • 쿠키는 HttpOnly 설정이 있다.
        • 해당 설정을 도입하면, JS 코드로 쿠키를 읽는 것이 불가능하다.
          • (하지만 프론트에서도 쿠키를 이용할 수 없는 단점이 생김)
      • CSRF 공격에 취약
        • Credentials 허용 시, 쿠키가 자동으로 request에 포함된다.
        • 백엔드가 해당 쿠키를 인증에 사용할 시, CSRF 공격에 취약하게 된다.

※ XSS

  • 상대방 PC에 내가 정의한 JS 코드를 실행시키는 것
  • 해당 JS 코드를 통해, LocalStorage나 쿠키 등을 조회하여 상대방 정보 탈취 가능
  • 예시(localStorage 탈취)
var stolenToken = localStorage.getItem("authToken");

 

 

※ CSRF

  • 사용자가 원하지 않는 Request를 보내게 하는 것
    • (로그인 된 사용자가 Request를 보내게 해서 사용자 정보가 담긴 Response를 획득하는 방법)
  • 시나리오
    • 사용자는 로그인 된 상태
    • 사용자는 공격자의 웹 사이트에 접속
    • 공격자는 사용자가 원치 않는 실행 코드를 실행시킴
//사용자는 의도치 않게 img를 누르면 다음과 같은 명령이 실행됨
<img src="http://bank.com/transfer?to=attacker&amount=1000">

 

사용자가 로그인 된 상태에서 해당 Request를 보낼 시, 공격자는 Request에 대한 Response 정보 탈취 가능

 

(https://github.com/goForItkang/teamproject_1_backend/tree/choeyoungju)