1. JWT 방식이란?

JWT (JSON Web Token)는 웹에서 로그인을 하거나 인증이 필요한 경우에 사용되는 "디지털 신분증"이라고 생각하면 쉬워요.

  • 예를 들어, 놀이공원에 갔을 때, 입장 티켓을 받잖아요? 그 티켓을 보여줘야 놀이기구를 탈 수 있어요. 이때 그 티켓이 바로 JWT이에요. 당신이 한 번 티켓을 받으면 그걸 가지고 놀이터 여러 곳에서 사용할 수 있는 거죠!

  • JWT는 클라이언트와 서버 간에 데이터를 안전하게 주고받기 위한 방식으로, 로그인을 하고 나면 서버가 클라이언트에게 "토큰"을 주고, 이 토큰을 나중에 API 요청 시 인증에 사용해요. 토큰은 로그인 후 일정 기간 동안 유효하고, 클라이언트는 요청마다 토큰을 헤더에 추가해 서버에 보냅니다. 이 방식은 세션을 서버에 저장하지 않아도 되기 때문에 분산 시스템에 유리하죠.


2. JWT를 사용하는 방법은 어떻게 되나요?

JWT 방식을 사용하는 기본적인 흐름을 살펴볼게요:

흐름 요약:

  1. 로그인 요청:
    사용자가 앱(React Native)에서 아이디와 비밀번호를 입력하고 서버에 로그인 요청을 보냅니다.
  2. 토큰 발급:
    서버(Spring Security)는 사용자를 인증하고, 인증이 성공하면 JWT 토큰을 발급해서 사용자의 핸드폰으로 보내줍니다.
  3. 토큰 저장:
    앱은 이 토큰을 저장해둡니다. 마치 놀이공원 입장 티켓을 주머니에 넣어두는 것처럼요.
  4. API 요청할 때 토큰 사용:
    이후, 서버에 데이터를 요청할 때마다 이 토큰을 '인증'서류처럼 서버에 보내서 "나 인증된 사용자야!"라고 말하는 겁니다.
  5. 서버는 토큰을 확인하고 유효하면 데이터를 보내줍니다.

3. 사용된 코드 설명

  • 이 프로그램은 로그인을 할 때 컴퓨터가 "이 사람이 진짜 로그인한 사람인가?"를 확인하고, 그 사람에게 입장 티켓(JWT)을 줘서 이후에는 그 티켓만 보여주면 자동으로 놀이기구를 탈 수 있게 도와줘요.

  • 위 코드는 Spring Security를 사용한 JWT 인증 설정입니다.

    • SecurityConfig:
      Spring Security에서 인증, 인가 설정을 담당하는 클래스입니다.
    • JwtTokenProvider:
      JWT 토큰을 생성하고, 검증하는 역할을 합니다.
    • JwtAuthenticationFilter:
      각 요청에서 JWT 토큰을 확인해 인증 여부를 결정하는 필터입니다.
    • Login.js:
      React Native에서 로그인 폼을 작성한 코드로, 서버에 로그인 요청을 보내고 JWT 토큰을 받아서 저장한 후, 이후 API 요청 시 이 토큰을 함께 보내도록 설정합니다.

4. 전체적인 개념을 정리하면:

  • 로그인하면 서버에서 JWT 토큰을 주고,
  • 이후 서버에 데이터를 요청할 때마다 이 토큰을 함께 보내서 인증을 받는 방식입니다.

이게 바로 웹과 앱에서 흔히 사용하는 JWT 방식 인증입니다.




SecurityConfig.java (서버 설정 파일)

@Configuration
@EnableWebSecurity
@ComponentScan(basePackages = "org.project.backend")
public class SecurityConfig {
  • 설명:
    • @Configuration:
      이 클래스는 스프링 설정 파일임을 의미합니다. 스프링에서는 이 클래스에서 설정한 내용을 바탕으로 보안 설정을 구성하게 됩니다.
    • @EnableWebSecurity:
      Spring Security를 활성화해 웹 보안 기능을 사용하겠다는 의미입니다.
    • @ComponentScan:
      org.project.backend 패키지에서 필요한 빈들을 찾아 자동으로 등록합니다.
    private final MemberDetailsService memberDetailsService;
  • 설명:
    MemberDetailsService는 사용자의 정보를 로드하는 서비스입니다. 이를 통해 Spring Security가 사용자 정보를 불러와 인증에 활용합니다.

    사용자의 정보를 로드하는 서비스라는 것은,
    사용자가 이전에 제공한 정보를 기억하고,
    그 정보를 바탕으로 다음 대화에서 참고하거나 반응할 수 있는 기능을 의미합니다.
    이를 통해 사용자가 한 번 제공한 정보를 반복해서 입력할 필요 없이, 대화의 연속성을 유지하고 더 맞춤화된 답변을 제공할 수 있습니다.

예를 들어, 사용자가 자신의 성격 유형, 직업 계획, 혹은 개인적인 관심사를 공유했다면, 그 정보를 기억하고 이후 대화에서 자연스럽게 그 내용을 반영할 수 있습니다. 이러한 서비스는 개인 맞춤형 경험을 제공하기 위해 사용됩니다.

    public SecurityConfig(MemberDetailsService memberDetailsService) {
        this.memberDetailsService = memberDetailsService;
    }
  • 설명: SecurityConfig 클래스의 생성자로, MemberDetailsService를 받아서 클래스 내부에서 사용합니다.

Security 설정

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
  • 설명:
    SecurityFilterChain을 통해 HTTP 요청을 어떻게 처리할지 정의하는 메소드입니다. 여기에서 로그인, 로그아웃, 권한 검증 등의 보안 정책을 설정합니다.
        http
                .csrf().disable()
  • 설명:
    CSRF(Cross-Site Request Forgery) 방어 기능을 비활성화합니다. JWT 방식에서는 CSRF 방어가 필요 없기 때문에 꺼두는 것이 일반적입니다.
                .authorizeRequests()
                .antMatchers("/", "/register", "/login", "/api/members/**").permitAll()
                .anyRequest().authenticated()
  • 설명:
    • authorizeRequests():
      들어오는 요청에 대한 보안 설정을 시작합니다.
    • antMatchers("/", "/register", "/login", "/api/members/**").permitAll():
      이 경로로 들어오는 요청은 인증 없이 누구나 접근할 수 있게 허용합니다.
    • anyRequest().authenticated():
      위에서 허용된 경로를 제외한 모든 요청은 인증된 사용자만 접근할 수 있게 설정합니다.
                .formLogin()
                .loginPage("/login")
                .loginProcessingUrl("/perform_login")
  • 설명:
    • formLogin():
      기본적인 로그인 기능을 사용하겠다는 의미입니다.
    • loginPage("/login"):
      사용자가 로그인 페이지로 이동할 경로를 지정합니다.
    • loginProcessingUrl("/perform_login"):
      실제 로그인을 처리할 URL을 설정합니다.
                .successHandler(new CustomAuthenticationSuccessHandler(jwtTokenProvider()))
                .failureHandler(new CustomAuthenticationFailureHandler())
  • 설명:
    • successHandler():
      로그인 성공 시 실행될 로직을 정의하는 핸들러를 지정합니다. 여기서는 JWT 토큰을 발급하는 CustomAuthenticationSuccessHandler가 사용됩니다.
    • failureHandler():
      로그인 실패 시 실행될 로직을 처리하는 핸들러를 정의합니다.
                .logout()
                .logoutUrl("/logout")
                .logoutSuccessHandler(new CustomLogoutSuccessHandler())
                .invalidateHttpSession(true)
                .deleteCookies("JSESSIONID");
  • 설명:
    • logout():
      로그아웃 기능을 설정합니다.
    • logoutUrl("/logout"):
      로그아웃 요청을 받을 URL을 지정합니다.
    • logoutSuccessHandler():
      로그아웃이 성공한 후 처리할 핸들러를 지정합니다.
    • invalidateHttpSession(true):
      세션을 무효화합니다.
    • deleteCookies("JSESSIONID"):
      쿠키를 삭제합니다. 세션이 필요 없으므로 세션 쿠키를 제거합니다.
        return http.build();
    }
  • 설명: 설정된 보안 필터 체인을 반환합니다.

비밀번호 암호화 설정

    @Bean
    public PasswordEncoder passwordEncoder() {
        String idForEncode = "bcrypt";
        Map<String, PasswordEncoder> encoders = new HashMap<>();
        encoders.put("bcrypt", new BCryptPasswordEncoder());
        encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
        return new DelegatingPasswordEncoder(idForEncode, encoders);
    }
  • 설명:
    • passwordEncoder():
      비밀번호를 안전하게 암호화하기 위한 설정입니다.
    • BCryptPasswordEncoder:
      bcrypt 알고리즘을 사용해 비밀번호를 암호화합니다. 매우 안전한 암호화 방식으로, 비밀번호를 저장할 때 사용됩니다.
    • DelegatingPasswordEncoder:
      여러 가지 암호화 방식을 지원하는 인코더입니다.
      기본적으로 bcrypt 방식을 사용하고 있지만,
      다른 방식도 사용할 수 있도록 설정되어 있습니다.

JWT 설정

    @Bean
    public JwtTokenProvider jwtTokenProvider() {
        return new JwtTokenProvider();
    }
  • 설명:
    JwtTokenProvider는 JWT 토큰을 생성하고 검증하는 기능을 제공하는 클래스입니다. 이 메소드에서 그 객체를 빈으로 등록하여 다른 곳에서 사용할 수 있도록 합니다.
    @Bean
    public JwtAuthenticationFilter jwtAuthenticationFilter() {
        return new JwtAuthenticationFilter(jwtTokenProvider(), memberDetailsService);
    }
  • 설명:
    JwtAuthenticationFilter는 HTTP 요청에서 JWT 토큰을 확인하고, 그 토큰을 사용해 사용자를 인증하는 필터입니다.

JwtTokenProvider.java (JWT 토큰 처리)

private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
  • 설명: 로그를 기록하기 위해 로거를 설정합니다. JWT 관련 예외 처리나 로그를 남길 때 사용됩니다.
private final String JWT_SECRET = "your-secret-key";
private final long JWT_EXPIRATION_MS = 604800000L;
  • 설명:
    • JWT_SECRET:
      JWT를 암호화하고 복호화할 때 사용할 비밀 키입니다.
      반드시 안전하게 관리해야 합니다.
    • JWT_EXPIRATION_MS:
      JWT의 만료 시간입니다.
      여기서는 7일(604800000 밀리초)로 설정되어 있습니다.
public String generateToken(Authentication authentication) {
    UserDetails userDetails = (UserDetails) authentication.getPrincipal();
    Date now = new Date();
    Date expiryDate = new Date(now.getTime() + JWT_EXPIRATION_MS);

    return Jwts.builder()
            .setSubject(userDetails.getUsername())
            .setIssuedAt(now)
            .setExpiration(expiryDate)
            .signWith(SignatureAlgorithm.HS512, JWT_SECRET)
            .compact();
}
  • 설명:
    • generateToken():
      사용자가 로그인하면 이 메소드가 호출되어 JWT 토큰을 생성합니다.
    • userDetails.getUsername():
      JWT 토큰의 "subject"로 사용자 이름을 설정합니다.
    • setIssuedAt(now):
      토큰이 발급된 시간을 설정합니다.
    • setExpiration(expiryDate):
      토큰의 만료 시간을 설정합니다.
    • signWith(SignatureAlgorithm.HS512, JWT_SECRET):
      HS512 알고리즘과 비밀 키를 사용해 토큰에 서명합니다.
    • compact():
      최종적으로 JWT 토큰을 문자열로 만듭니다.
public String getUsernameFromJWT(String token) {
    Claims claims = Jwts.parser()
            .setSigningKey(JWT_SECRET)
            .parseClaimsJws(token)
            .getBody();
    return claims.getSubject();
}
  • 설명:
    이 메소드는 JWT 토큰에서 사용자 이름을 추출합니다.
    서버가 요청을 받을 때 이 정보를 사용해 누가 요청을 보냈는지 확인할 수 있습니다.
public boolean validateToken(String token) {
    try {
        Jwts.parser().setSigningKey(JWT_SECRET).parseClaimsJws(token);
        return true;
    } catch (ExpiredJwtException ex) {
        logger.error("Expired JWT token", ex);
    } catch (Exception ex) {
        logger.error("Invalid JWT token", ex);
    }
    return false;
}
  • 설명:
    JWT 토큰이 유효한지 검증하는 메소드입니다.
    • try 블록에서 토큰이 유효한지 확인합니다.
    • 토큰이 만료됐거나 잘못된 경우 예외가 발생하면 로그를 남기고 false를 반환합니다.

JwtAuthenticationFilter.java (JWT 인증 필터)

@Override
protected void doFilterInternal(HttpServletRequest request,
                                HttpServletResponse response,
                                FilterChain filterChain) throws ServletException, IOException {
    String jwt = getJwtFromRequest(request);
    try {
        if (jwt != null && jwtTokenProvider.validateToken(jwt)) {
            String username = jwtTokenProvider.getUsernameFromJWT(jwt);
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);
            UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                    userDetails, null, userDetails.getAuthorities());
            authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
    } catch (JwtException ex) {
        logger.error("Could not set user authentication in security context", ex);
    }
    filterChain.doFilter(request, response);
}

• 설명:
• doFilterInternal(): HTTP 요청이 들어올 때마다 호출되는 메소드로, 여기서 JWT 토큰을 확인하고 유효성을 검사합니다.
• getJwtFromRequest(): HTTP 요청 헤더에서 JWT 토큰을 추출합니다.
• validateToken(): 추출된 JWT 토큰의 유효성을 검사합니다.
• loadUserByUsername(): JWT 토큰에서 사용자 이름을 추출한 후, 데이터베이스에서 해당 사용자 정보를 가져옵니다.
• UsernamePasswordAuthenticationToken: 가져온 사용자 정보로 인증 객체를 생성합니다.
• SecurityContextHolder: 스프링 시큐리티의 보안 컨텍스트에 이 인증 정보를 설정하여 이후 요청에서 인증된 사용자로 처리되도록 합니다.

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

설명:
• 이 메소드는 요청 헤더에서 Authorization 값을 가져와 JWT 토큰을 추출합니다. Bearer 로 시작하는 부분을 제외하고 토큰 값만 반환합니다.

이 코드는 JWT를 사용한 인증을 React Native와 Spring Boot 서버에서 처리하는 전체 흐름을 보여줍니다. React Native 클라이언트는 서버로 로그인 요청을 보내고, 서버는 JWT 토큰을 발급해 줍니다. 이후 API 요청마다 클라이언트는 이 토큰을 보내 인증을 받고 서버는 해당 요청을 처리합니다.




이 기능을 사용하고자 하는 예비 개발자들에게 중요한 몇 가지 추가 정보

이 기능을 사용하고자 하는 예비 개발자들에게 중요한 몇 가지 추가 정보를 알려드릴게요.
JWT 인증 방식은 다양한 프로젝트에서 사용할 수 있지만, 올바르게 설계하고 안전하게 사용하기 위해서는 몇 가지 추가 고려 사항이 필요합니다.


1. JWT 토큰의 구조

JWT 토큰은 세 부분으로 구성되어 있습니다:

  • Header:
    토큰의 타입(JWT)과 서명 알고리즘(예: HS256) 정보를 담고 있습니다.
  • Payload:
    사용자 정보와 같은 데이터가 저장됩니다.
    중요한 정보는 여기 저장하지 않는 것이 좋습니다.
  • Signature:
    비밀 키를 사용해 생성한 서명으로, 토큰이 변조되지 않았음을 보장합니다.

이러한 구조를 잘 이해하고, Payload에 저장하는 정보가 너무 많지 않도록 주의해야 합니다. 민감한 정보는 저장하지 않는 것이 원칙입니다.


2. JWT 토큰 저장 위치

JWT 토큰을 클라이언트 측에서 어떻게 저장하고 관리할지 신중히 결정해야 합니다.

  • 로컬 저장소(Local Storage):
    브라우저나 클라이언트 앱의 로컬 스토리지에 토큰을 저장할 수 있습니다. 하지만 XSS(Cross-Site Scripting) 공격에 취약할 수 있으므로 주의가 필요합니다.
  • 쿠키(Cookies):
    쿠키에 토큰을 저장할 수 있으며, HttpOnly 플래그를 설정하면 자바스크립트에서 접근할 수 없으므로 XSS 공격에 대한 방어가 가능합니다. 하지만 CSRF 공격에 취약할 수 있으므로 주의가 필요합니다.

추천: 모바일 앱에서는 주로 AsyncStorageSecureStorage 같은 안전한 저장소를 이용하는 것이 좋습니다.


3. JWT 토큰의 만료와 갱신 (Refresh Token)

JWT 토큰은 만료 시간이 설정되어 있으므로, 시간이 지나면 토큰이 더 이상 유효하지 않게 됩니다. 따라서 만료된 토큰을 갱신하는 방법을 고려해야 합니다.

  • Access Token (JWT):
    이 토큰은 짧은 시간 동안 유효합니다 (예: 15분~1시간). 요청마다 서버에서 인증을 받을 때 사용됩니다.
  • Refresh Token: A
    ccess Token이 만료되었을 때, Refresh Token을 이용해 새로운 Access Token을 발급받습니다. Refresh Token은 더 긴 만료 시간을 가지며, 서버에서만 저장됩니다. (클라이언트가 서버에 새 토큰을 요청할 때만 사용)

Tip:
Refresh Token은 상대적으로 안전하게 관리해야 하며, 서버에서 저장하고 관리하는 것이 좋습니다. 이를 통해 만료된 토큰을 재발급할 수 있는 흐름을 만들 수 있습니다.


4. 보안 고려 사항

JWT를 사용할 때는 보안에 대한 여러 가지 고려 사항이 중요합니다.

  • 토큰의 서명(Signing):
    토큰을 안전하게 서명하기 위해서는 강력한 비밀 키를 사용해야 합니다. 특히 HS256과 같은 알고리즘을 사용하는 경우, 비밀 키를 안전하게 관리하고 적절히 길이를 설정해야 합니다. 강력한 서명 알고리즘으로 RSA 또는 ECDSA 같은 비대칭 키 서명을 사용하는 것도 좋은 방법입니다.

  • 토큰의 유효성 검증:
    서버에서 JWT 토큰을 검증할 때는 서명뿐만 아니라 토큰의 만료 시간, 발급자 정보, 그리고 예상하는 사용자 정보와 일치하는지 확인해야 합니다.

  • HTTPS 사용:
    클라이언트와 서버 간에 주고받는 모든 JWT 토큰은 반드시 HTTPS를 통해 암호화된 채널로 전송해야 합니다. 그렇지 않으면 토큰이 노출될 위험이 있습니다.


5. 로그아웃 구현

JWT 방식에서는 서버 측 세션을 사용하지 않기 때문에, 클라이언트에서 로그아웃 기능을 구현할 때 몇 가지 방법이 있습니다.

  • 클라이언트 측 토큰 삭제:
    로그아웃 시 클라이언트에 저장된 토큰을 삭제하면 사용자는 더 이상 API를 호출할 수 없습니다.
  • 서버 측 토큰 블랙리스트:
    만약 토큰을 무효화하고 싶다면, 서버에서 특정 토큰을 "블랙리스트"에 추가하여 해당 토큰이 더 이상 유효하지 않도록 할 수 있습니다. 하지만 이 방식은 서버에 저장 공간이 필요하고 관리가 복잡할 수 있습니다.

6. CORS 정책

React Native 앱이 Spring Boot 서버에 요청을 보낼 때, 특히 다른 도메인에서 요청이 발생하는 경우 CORS(Cross-Origin Resource Sharing) 문제가 발생할 수 있습니다. 서버에서 CORS 정책을 설정해 요청을 허용해야 합니다.

@Bean
public WebMvcConfigurer corsConfigurer() {
    return new WebMvcConfigurer() {
        @Override
        public void addCorsMappings(CorsRegistry registry) {
            registry.addMapping("/**").allowedOrigins("http://localhost:3000");
        }
    };
}

이렇게 CORS 설정을 추가해 특정 도메인(예: http://localhost:3000)에서 오는 요청을 허용할 수 있습니다.


7. Spring Security 디버깅 팁

Spring Security와 JWT를 연동하는 과정에서 발생할 수 있는 여러 가지 문제를 디버깅할 수 있는 방법을 알아두면 좋습니다.

  • 로그: Spring Security는 로깅 기능이 강력합니다. application.properties 파일에 다음과 같은 설정을 추가하면 더 많은 디버깅 정보를 확인할 수 있습니다.
logging.level.org.springframework.security=DEBUG
  • 필터 체인 확인:
    요청이 들어왔을 때 어떤 필터가 어떻게 작동하는지 로그를 통해 확인할 수 있습니다. 특히 JwtAuthenticationFilter에서 토큰을 올바르게 읽어오는지 확인하는 것이 중요합니다.

8. React Native에서의 Axios 사용

React Native 앱에서 API 호출 시 axios를 사용해 요청을 보낼 때,
매번 토큰을 추가하는 작업을 자동화할 수 있습니다.

  • Axios 인스턴스 생성: 로그인 후 발급받은 토큰을 저장한 뒤, 토큰을 포함한 axios 인스턴스를 만들어 모든 요청에서 이 인스턴스를 사용하도록 설정할 수 있습니다.
const createAxiosInstance = (token) => {
  return axios.create({
    baseURL: 'http://your-api-url.com',
    headers: {
      'Authorization': `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
  });
};

이렇게 만들어진 인스턴스를 사용하면 이후 모든 API 요청에서 자동으로 토큰이 포함됩니다.


9. 에러 핸들링

React Native 앱에서 로그인 요청을 보낼 때 실패할 경우 사용자에게 적절한 에러 메시지를 제공해야 합니다.

  • HTTP 상태 코드 처리:
    서버에서 반환하는 상태 코드에 따라 다른 처리를 할 수 있습니다. 예를 들어 401(인증 실패) 오류가 발생하면 로그인 화면으로 되돌아가거나, 유효하지 않은 토큰이라는 메시지를 보여줄 수 있습니다.
try {
  const response = await axios.post('/login', credentials);
  // 로그인 성공
} catch (error) {
  if (error.response && error.response.status === 401) {
    Alert.alert("Error", "Invalid username or password.");
  } else {
    console.error("Login Error:", error);
    Alert.alert("Error", "An unexpected error occurred.");
  }
}

마무리

JWT 기반 인증 시스템을 React Native와 Spring Boot에서 연동하는 방식은 널리 사용되는 방법입니다. 토큰을 어떻게 관리하고, 만료 처리나 보안 강화를 어떻게 할지 고민하면서 구현하면 안정적이고 확장성 있는 인증 시스템을 만들 수 있습니다.

계속해서 실습을 통해 이해를 높여가면서, 위의 권장 사항들을 적용해 보세요!

0개의 댓글