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
복사