jwt

JWT 이해하기와 활용 방안

로드존슨 2023. 3. 30. 17:46
728x90

SESSION 및 JWT(JSON 웹 토큰)는 모두 웹 애플리케이션에서 사용자를 인증하고 권한을 부여하는 방법입니다.

 

SESSION

 로그인시 각 사용자에게 고유 식별자 (세션ID)가 할당되고 이 ID가 서버측에 저정되는 서버측 메커니즘이다.

 사용자가 서버에 요청을 보낼 때마다 서버는 세션 ID를 확인하여 사용자를 식별하고 세션데이터를 검색!!

->세션을 검증하기위해서는 서버에서 세션 데이터를 저장하고 관리해야 하므로 서버에 부담이 된다.

 

JWT(STATELESS)

사용자 인증시 토큰이 생성되고 서버에서 비밀 키로 서명되는 클라이언트 측 메커니즘이다.

사용자가 서버에 요청을 보낼 때마다 요청 헤더에 토큰이 포함되고 서버는 토큰의 서명을 확인하고 사용자 정보를 추출한다.JWT는 비저장이며 확장성과 유지관리가 쉽다.

 

JWT는 다른 도메인에서도 사용할 수 있지만 SESSION은 다른 도메인은 제한된다. 전반적으로 JWT는 사용자 인증 및 권한 부여에 보다 유연하고 확장 가능한 접근 방식을 제공한다.

 

 

 

JWT 토큰 구성

Header는 토큰의 타입과 해시 암호화 알고리즘으로 구성

Payload는 토큰에 담을 클레임(claim) 정보를 포함.민감한 정보를 담지 않는

Signature는 secret key를 포함하여 암호화

 

JWT 인증 절차

  1. 사용자 로그인 요청
  2. 서버에서 암호화된 토큰 생성
  3. 사용자는 토큰을 저장
  4. 이 후 모든 요청에 토큰을 태워 요청함
  5. 서버는 토큰을 가지고 사용자 식별

 

JWT에는 몇 가지 약점과 잠재적인 보안 문제

  • 토큰은 클라이언트에 저장되어 데이터베이스에서 사용자 정보를 조작하더라도 토큰에 직접 적용할 수 없습니다.
  • 더 많은 필드가 추가되면 토큰이 커질 수 있습니다.(최소의 정보를 담아 토큰 생성)
  • 비상태 애플리케이션에서 토큰은 거의 모든 요청에 대해 전송되므로 데이터 트래픽 크기에 영향을 미칠 수 있습니다.

보안이슈!!

  1. JWT 주입 및 변조와 같은 공격에 대한 취약성: 공격자가 토큰을 조작하고 내용을 수정할 수 있어 보안 취약성이 발생할 수 있습니다. 이는 토큰을 보호하기 위해 암호화 및 디지털 서명을 사용하여 완화
  2. 토큰 도난 및 재생 공격: 공격자가 JWT를 훔치면 탐지되지 않고 이를 사용하여 보호된 리소스에 액세스할 수 있습니다. 이는 수명이 짧은 토큰을 사용하고 토큰 취소 및 갱신을 위한 메커니즘을 구현하여 완화
  3. 세션 무효화에 대한 지원 부족: JWT는 상태 비저장이므로 일단 발급되면 무효화하기 어렵습니다. 이것은 짧은 만료 시간을 설정하고 토큰 취소 메커니즘을 구현하여 해결
  4. 세분화된 액세스 제어 부족: JWT는 세분화된 액세스 제어를 제공하지 않습니다. 즉, 사용자가 주어진 토큰으로 수행할 수 있는 작업을 제어하기 어려울 수 있습니다. 이는 RBAC(역할 기반 액세스 제어) 또는 ABAC(속성 기반 액세스 제어)를 구현하여 완화

 

적용

1.디펜던시 설정

2.yaml파일 jwt 설정값 추가

3.시큐리티 Config 설정에 jwtFilter 추가 및  예외처리(CustomAuthenticationEntryPoint)추가 

- JwtFilter를 통해 Security로직에 필터를 등록한다.

- CustomAuthenticationEntryPoint 는 유효한 자격증명을 제공하지 않고 접근하려할때 에러를 리턴하는 클래

4.jwtFilter 구현  및 CustomAuthenticationEntryPoint 구현

- jwtFilter 에 doFilterInternal 는 토큰의 인증정보를 SecurityContext 에 저장하는 역할 수행

5.jwtUtils구현 

6.로그인 성공시 토큰 값 저장기능 구현

 

 

 

 

디펜던시 설정

 

yaml 파일에 jwt 설정 

jwt:
  secret-key: fast-campus.simple_sns_2022_secret_key
  # 30 days
  token.expired-time-ms: 2592000000

CONFIG 설정 

 

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class AuthenticationConfiguration extends WebSecurityConfigurerAdapter {

    private final UserService userService;
    @Value("${jwt.secret-key}")
    private String secretKey;


    @Override
    public void configure(WebSecurity web) throws Exception {       
        web.ignoring().regexMatchers("^(?!/api/).*");

    }
    
    ===/api/로 시작하지 않는 모든 요청에 대해 인증 및 권한 부여 필터를 적용하지 않는다.
    
    

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .authorizeRequests()
                .antMatchers("/api/*/users/join", "/api/*/users/login").permitAll()
                .antMatchers("/api/*/users/alarm/subscribe/*").permitAll()
                .antMatchers("/api/**").authenticated()
                .anyRequest().permitAll()
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)  //session 적용은 안시킨다는 의미
                .and()
                .exceptionHandling()           // 예외처리를 구성하는 메소드
                .authenticationEntryPoint(new CustomAuthenticationEntryPoint()) //에외처리 호출메서드
                .and()
                .addFilterBefore(new JwtTokenFilter(userService, secretKey), UsernamePasswordAuthenticationFilter.class);
    }			// 다른필터보다 jwt 필터를 우선적용하겠다. 인증권한검증우선 후 타필터 적용

}

 

jwt 필터 미인증시 에외처리 

public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        response.setContentType("application/json");
        response.setStatus(ErrorCode.INVALID_TOKEN.getStatus().value());
        response.getWriter().write(Response.error(ErrorCode.INVALID_TOKEN.name()).toStream());//이쁘게 보여주기 위해 toStream 쓴다.
    }
}

해당 메서드는 사용자 가 적절한 인증 없이 보호된 리소스에 엑세스 하려고 할 때 호출되며 토큰이 잘못되었음을 나타태는 오류 메시지이다. 이 메서드는 응답 콘텐츠 유형을 json으로 설정하고,Response.error 메서드를 사용하여 응답 출력스트림에 오류 메시지를 쓴다.

 

 

 

 

jwt 필터

@Slf4j
@RequiredArgsConstructor
public class JwtTokenFilter extends OncePerRequestFilter {

    private final UserService userService;

    private final String secretKey;


    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain)
            throws ServletException, IOException {
        final String header = request.getHeader(HttpHeaders.AUTHORIZATION);
        //인증헤더부분을 꺼내 토큰유무 확인
        //jwt 토큰을 Bearer 토큰에 넣어주기에 이름이 "Bearer [토큰값]" 로 시작이 된다
        final String token;
        try {
            if (TOKEN_IN_PARAM_URLS.contains(request.getRequestURI())) {
                log.info("Request with {} check the query param", request.getRequestURI());
                token = request.getQueryString().split("=")[1].trim();  //토큰 번호만 불어온다.
            } else if (header == null || !header.startsWith("Bearer ")) {
            	
                log.error("Authorization Header does not start with Bearer {}", request.getRequestURI());
                chain.doFilter(request, response);
                return;
            } else {
                token = header.split(" ")[1].trim();
                //공백을 기준으로 bearer 와 토큰을 분리하여 토큰값만 불러온다.
            }

            String userName = JwtTokenUtils.getUsername(token, secretKey);
            User userDetails = userService.loadUserByUsername(userName);

            if (!JwtTokenUtils.validate(token, userDetails.getUsername(), secretKey)) {
                chain.doFilter(request, response);
                return;
            }
            UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                    userDetails, null,
                    userDetails.getAuthorities()
            );
            authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            SecurityContextHolder.getContext().setAuthentication(authentication);
        } catch (RuntimeException e) {
            chain.doFilter(request, response);
            return;
        }

        chain.doFilter(request, response);

    }
}

 

 

OncePerRequestFilter : 필터 객체 생성과 초기화를 단 한번 수행하므로 성능상 이점 

doFilterInternal : if문을 통해 토큰값 유무를 확인하고 확인된 정보(request,response) 를 담아 filterChain.doFilter 메소드를 통해 다음 필터로 위임처리. 각 필터마다 역할이 다르니까 필터를 하나씩 걸려내면서 다음 필터에 위임처리진행된다.

UsernamePasswordAuthenticationToken: 인증 주체에게 자격증명, 권한정보를 부여한다.

UsernamePasswordAuthenticationToken 의 각 파라매터 값은 

(인증 주체를 나타내는 객체, 자격증명객체,주체가 가지고 있는 권한정보를 나타내는 객체)

 

jwtTokenUtils

public class JwtTokenUtils {
	//토큰 검증 
    public static Boolean validate(String token, String userName, String key) {
        String usernameByToken = getUsername(token, key);
        return usernameByToken.equals(userName) && !isTokenExpired(token, key);
    }
    
    
	//토큰값 파싱하여 원래 데이터값을 반환
    public static Claims extractAllClaims(String token, String key) {
        return Jwts.parserBuilder() // 파싱하기 위한 도구
                .setSigningKey(getSigningKey(key))
                .build()
                .parseClaimsJws(token)
                .getBody();  //원데이터를 반환
    }
	
    //토큰값을 파싱하여 유저이름 반환
    public static String getUsername(String token, String key) {
        return extractAllClaims(token, key).get("username", String.class);
    }

	//암호화키를 반환
    private static Key getSigningKey(String secretKey) { 
        byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8); //키를 바이트로 반환한 다음
        return Keys.hmacShaKeyFor(keyBytes); //hmacSha 알고리즘을 사용하여 암호화키 반환
    }
    
    
	//만료시간 유효검증
    public static Boolean isTokenExpired(String token, String key) {
        Date expiration = extractAllClaims(token, key).getExpiration();
        return expiration.before(new Date());
    }
	
    //토큰 생성
    public static String generateAccessToken(String username, String key, long expiredTimeMs) {
        return doGenerateToken(username, expiredTimeMs, key);
    }

	//토큰 생성 구현
    private static String doGenerateToken(String username, long expireTime, String key) {
        Claims claims = Jwts.claims();  //Jwt 라이브러리 claims를 통해 토큰 생성 
        claims.put("username", username);

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + expireTime))
                .signWith(getSigningKey(key), SignatureAlgorithm.HS256)
                .compact();
    }
}

 

로그인성시 토큰생성

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserEntityRepository userRepository;
    private final AlarmEntityRepository alarmEntityRepository;
    private final BCryptPasswordEncoder encoder;
    private final UserCacheRepository redisRepository;



    @Value("${jwt.secret-key}")
    private String secretKey;

    @Value("${jwt.token.expired-time-ms}")
    private Long expiredTimeMs;

	
    public User loadUserByUsername(String userName) throws UsernameNotFoundException {
        return redisRepository.getUser(userName).orElseGet(
                        () -> userRepository.findByUserName(userName).map(User::fromEntity).orElseThrow(
                        () -> new SimpleSnsApplicationException(ErrorCode.USER_NOT_FOUND, String.format("userName is %s", userName))
                ));
    }

    public String login(String userName, String password) {
        User savedUser = loadUserByUsername(userName);
        redisRepository.setUser(savedUser);

        if (!encoder.matches(password, savedUser.getPassword())) {
            throw new SimpleSnsApplicationException(ErrorCode.INVALID_PASSWORD);
        }
        return JwtTokenUtils.generateAccessToken(userName, secretKey, expiredTimeMs);
    }

 

리프레시토큰은 공부하고 채우는걸로 .. 

 

 

https://www.inflearn.com/course/lecture?courseSlug=%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-jwt&unitId=64403

728x90