Backend
home
🕔

2024. 9. 9 (JWT 토큰)

생성일
2025/01/24 05:52
태그

토큰 기반 인증

사용자 인증 확인 방법을 두 가지로 나누면, 세션 기반 인증(서버 기반 인증)과 토큰 기반 인증이 있다.
토큰은 서버에서 요청을 받을 때, 요청을 보낸 클라이언트를 구분하기 위한 유일한 값이다.
서버토큰을 생성해서 클라이언트에게 제공하면, 클라이언트는 해당 토큰을 보관하고 있다가 여러 요청을 토큰과 함께 보낸다 (토큰 = 신분증)
서버는 토큰을 확인 후 해당 클라이언트가 유효한 사용자인지 검증하고, 요청을 처리해준다.

토큰 기반 인증 특징

무상태성
사용자의 인증 정보가 담겨 있는 토큰이 클라이언트에서 보관되기 때문에, 서버는 이를 저장할 필요가 없다.
서버가 모든 데이터를 갖고 있으려면 많은 자원을 소비하게 되는데, 토큰 기반 인증은 클라이언트가 토큰을 관리한다.
이렇게 하면 서버는 클라이언트의 인증 정보를 저장하거나 유지하지 않아도 되기 때문에 완전한 무상태로 효율적인 검증이 가능해진다.
확장성
서버를 확인할 때 상태 관리에 대한 신경을 쓸 필요가 없어지기 때문에 서버 확장에 용이해진다.
예를 들어 결제 서버와 주문 서버가 따로 분리되어 있더라도, 하나의 토큰으로 결제 서버와 주문 서버에게 요청을 보낼 수 있게 된다.
추가로 SNS 로그인과 같이 토큰 기반 인증을 사용하는 다른 시스템에 접근해 로그인 방식을 확장할 수도 있다.
무결성
토큰 방식은 HMAC(Hash-based Maessage AuthentiCation)기법 이라고도 부르는데, 토큰을 발급한 이후에는 토큰 정보를 변경할 수 없다.
만약에 누군가가 토큰을 한 글자라도 변경하면, 서버에서는 유효하지 않은 토큰으로 판단한다.

JWT

발급받은 JWT를 이용해 인증을 하려면 HTTP 요청 헤더 중에 Authorization 값에 Bearer + JWT토큰값 을 넣어서 보내야 한다.
JWT은 . 을 기준으로 헤더(Header), 내용(Payload), 서명(Signature)으로 구성되어 있다.
헤더 : 토큰의 타입과 해싱 알고리즘을 지정하는 정보.
내용
토큰과 관련된 정보로 내용의 한 덩어리를 클레임(Claim) 이라고 부르며, 클레임은 키와 값의 한 쌍으로 이루어져 있다. 클레임은 등록된 클레임, 공개 클래임, 비공개 클레임으로 나뉜다.
등록된 클레임은 토큰에 대한 정보를 담는데 사용된다. iss : 토큰 발급자 sub: 토큰 제목 aud: 토큰 대상자 exp: 토큰 만료 시간 nbf: 토큰의 활성 날짜 iat: 토큰 발급 시간 jti: JWT의 고유 식별자 (주로 일회용 토큰에 사용)
서명 : 해당 토큰이 조작되었거나 변경되지 않았음을 확인하는 비밀키.
토큰을 주고 받는 환경에서 보안 취약점으로 인해 토큰이 노출되면 탈취가 가능해진다.
토큰을 탈취한 사람이 요청을 보내도 서버는 이를 알아차릴 수 없기에 정상적으로 요청을 처리한다.
토큰은 유효기간을 설정할 수 있다.
일반 토큰(액세스 토큰)에 리프레시 토큰을 추가하여 이용한다.
리프레시 토큰은 엑세스 토큰이 만료되었을 때, 새로운 액세스 토큰을 발급하기 위해 사용한다.
따라서 액세스 토큰의 유효 기간을 짧게 설정하고, 리프레시 토큰의 유효기간을 길게 설정하면, 공격자가 액세스 토큰을 탈취하더라도 리프레시 토큰을 알지 못하면 사용 불가능한 토큰이 되어버리기 때문에 보다 안전해진다.
Jwt 관련 핵심 코드 정리
package com.kosta.config; import lombok.Getter; import lombok.Setter; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; @Getter @Setter @Component @ConfigurationProperties("jwt") // application.yml 에 설정한 jwt.issuer, jwt.secret_key... 이 매핑된다. public class JwtProperties { private String issuer; private String secretKey; private int accessDuration; }
Java
복사
package com.kosta.config; import com.kosta.entity.User; import com.kosta.service.UserDetailServiceImpl; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Service; import javax.crypto.SecretKey; import java.util.Date; @Slf4j @Service @RequiredArgsConstructor public class JwtProvider { // JWT 관련 설정 정보 객체 주입 private final JwtProperties jwtProperties; private UserDetailServiceImpl userDetailsService; // JWT Access 토큰 생성 public String generateAccessToken(User user) { log.info("[generateAccessToken] 토큰을 생성합니다."); Date now = new Date(); Date expiredDate = new Date(now.getTime() + jwtProperties.getAccessDuration()); return makeToken(user, expiredDate); } // JWT 토큰 생성 메서드 private String makeToken(User user, Date expiredDate) { String token = Jwts.builder() .header().add("typ", "JWT") // JWT의 타입을 명시 .and() .issuer(jwtProperties.getIssuer()) // 발행자 정보 설정 .issuedAt(new Date()) // 발행일시 설정 .expiration(expiredDate) // 만료시간 설정 .subject(user.getEmail()) // 토큰의 주제(Subject) 설정 _ 사용자 이메일 .claim("id", user.getId()) .claim("role", user.getRole().name()) .signWith(getSecretKey(), Jwts.SIG.HS256) // 비밀키와 해시 알고리즘 사용하여 토큰 설명값 설정 .compact(); // 토큰 정보들을 최종적으로 압축해서 문자열로 반환 log.info("[makeToken] 완성된 토큰 : {}", token); return token; } // 비밀키 만드는 메서드 private SecretKey getSecretKey() { return Keys.hmacShaKeyFor(jwtProperties.getSecretKey().getBytes()); } // JWT 토큰 유효성 검증 메서드 public boolean validToken(String token) { log.info("[validToken] 토큰 검증을 시작합니다."); try { Jwts.parser(). verifyWith(getSecretKey()) // 비밀키로 서명 검증 .build() .parseSignedClaims(token); // 서명된 클레임을 파싱.. log.info("[validToken] 토큰 검증 통과."); return true; } catch (Exception e) { e.printStackTrace(); } log.info("[validToken] 토큰 검증 실패."); return false; } // JWT 토큰에서 정보(Claim) 추출 메서드 private Claims getClaims(String token) { log.info("[getClaims] 토큰에서 정보(Claim) 추출"); return Jwts.parser() .verifyWith(getSecretKey()) // 비밀키로 서명 검증 .build() .parseSignedClaims(token) // 서명된 클레임을 파싱 .getPayload(); // 파싱된 클레임에서 페이로드(실제 클레임)를 반환 } // JWT 토큰에서 인증 정보 반환하는 메서드 public Authentication getAuthenticationByToken(String token) { log.info("[getAuthenticationByToken] 토큰 인증 정보 조회"); String userEmail = getUserEmailByToken(token); User user = (User) userDetailsService.loadUserByUsername(userEmail); Authentication authentication = new UsernamePasswordAuthenticationToken( user, token, user.getAuthorities() ); return authentication; } // JWT 토큰에서 사용자 ID만 추출하는 메서드 public String getUserEmailByToken(String token) { log.info("[getUserEmailByToken] 사용자 ID 추출"); Claims claims = getClaims(token); String email = claims.get("email", String.class); return email; } }
Java
복사
Sprint Security 쪽 코드
package com.kosta.config; import com.kosta.service.UserDetailServiceImpl; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; 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.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.HttpBasicConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; @Configuration @EnableWebSecurity @RequiredArgsConstructor public class WebSecurityConfig { private final UserDetailsService userDetailsService; private final JwtProperties jwtProperties; // JWT Provider @Bean JwtProvider jwtProvider() { return new JwtProvider(jwtProperties, userDetailsService); } // 암호화 빈 @Bean BCryptPasswordEncoder bCryptPasswordEncoder() { return new BCryptPasswordEncoder(); } // HTTP 요청에 따른 보안 구성 @Bean SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 경로 권한 설정 http.authorizeHttpRequests(auth -> // 특정 URL 경로에 대해서는 인증 없이 접근 가능 auth.requestMatchers( new AntPathRequestMatcher("/api/auth/login"), // 로그인 new AntPathRequestMatcher("/api/auth/signup"), // 회원가입 new AntPathRequestMatcher("/api/auth/duplicate") // 이메일 중복체크 ).permitAll() // AuthController 중 나머지들은 "ADMIN"만 가능 .requestMatchers( new AntPathRequestMatcher("/api/auth/**") // "ADMIN"만 가능 ).hasRole("ADMIN") // 그 밖의 다른 요청들은 인증을 통과한(로그인한) 사용자라면 모두 접근할 수 있도록 한다. .anyRequest().authenticated() ); // 무상태성 세션 관리 http.sessionManagement((sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))); // (토큰을 통해 검증할 수 있도록) 필터 추가 http.addFilterBefore(new JwtAuthenticationFilter(jwtProvider()), UsernamePasswordAuthenticationFilter.class); // HTTP 기본 설정 http.httpBasic(HttpBasicConfigurer::disable); // CSRF 비활성화 http.csrf(AbstractHttpConfigurer::disable); // CORS 비활성화 (나중에 변경) http.cors(AbstractHttpConfigurer::disable); return http.getOrBuild(); } }
Java
복사
실습 테스트
=== raw-data === { "email": "testshow@gmail.com", "password": "minsung1234" } === 결과 === { "accessToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJrb3N0YS1obXMiLCJpYXQiOjE3MjU4NjI0NjMsImV4cCI6MTcyNTg2NDI2Mywic3ViIjoidGVzdHNob3dAZ21haWwuY29tIiwiaWQiOjE1LCJyb2xlIjoiUk9MRV9VU0VSIn0.PP1ARQ3OzUtjxupny4Lv-OqQfzcnozQutjgLB8r_X7o" }
JSON
복사
JWT Token 확인
react 상에서 jwt 관련 패키지 추가
$ yarn add jwt-decode
Java
복사