반응형

JWT란?

( JSON Web Token)는 웹 표준으로 정의된 JSON 기반의 토큰으로 클라이언트와 서버 간의 정보를 안전하게 전송하기 위해 사용한다.

 

 

JWT를 사용하는 이유

1. Stateless(무상태)

JWT 토큰 방식은 클라이언트 측에서 JWT를 서버에 전송하고 서버 측에서는 토큰을 검증하여 인증 정보를 확인하는 방식이다. 따라서, 각각의 요청은 독립적이고, 서버에서 상태를 유지하지 않으므로 여러 대의 서버에서 작업을 분산하거나 새로운 서버를 추가하더라도 기존 세션 정보를 전달하거나 공유할 필요가 없어진다.

 

 

2.Security(보안성)

서버에서는 JWT 토큰을 발급할 때, 비밀키를 사용하여 서명하고 클라이언트에서 토큰을 수신하면, 서버에서 전송한 비밀키를 사용하여 서명이 유효한지 확인한다. 따라서, JWT는 위조나 변조를 방지하고 토큰의 만료 시간을 지정하여 보안을 강화할 수 있다. 만료 시간이 지난 토큰은 더 이상 유효하지 않기 때문에, 보안상의 문제를 최소화할 수 있다.

 

 

3. Scalable(확장성)

JWT는 클라이언트와 서버 간의 인증 정보를 전송하는 데 사용되므로, API 기반 애플리케이션에서 유용하다. API는 대부분 다양한 클라이언트와 서버 간의 통신을 지원해야 하며, JWT를 사용하면 이러한 인증 및 권한 부여 작업을 효율적으로 수행할 수 있다.

 

 

4. Performance(성능)

JWT는 작은 크기를 가지므로 네트워크 대역폭을 적게 사용한다. 또한, 서버 측에서 JWT를 디코딩하고 검증하는 데 필요한 리소스가 적기 때문에 성능이 좋다.

 

 

JWT의 구조

 

 

헤더(Header)

 

{
  "alg": "HS256",
}

 

- JWT 토큰의 유형 및 알고리즘 정보가 JSON 형태로 들어가며

- 이 예제에서 "alg"는 알고리즘을 의미하며, "HS256"은 HMAC SHA-256 알고리즘을 사용한다는 것을 나타낸다.

 

 

 

페이로드(Payload)

 

{
  "sub": "sjmoon",
  "iat": 1681902855
  "exp": 1681903155
}

 

- 사용자 ID, 권한, 만료 일자 등 JWT 토큰에 포함될 정보가 JSON 형태로 들어간다.

- "sub"는 subject의 약어로, 토큰의 주체를 나타내고, 여기서는 사용자 ID를 의미한다.

- "iat"은 토큰이 발급된 시간을 의미한다.

- "exp"는 토큰의 유효시간을 의미한다.

 

 

 

서명(Signature)

 

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)

 

- 토큰이 변조되었는지 여부를 확인할 때 사용된다.

- "HMACSHA256"은 HMAC SHA-256 알고리즘을 사용한다는 것을 의미한다.

- "base64UrlEncode"는 Base64로 인코딩하고 URL 안전 문자로 치환하는 함수를 의미한다.

- "secret"은 서버에서 가지고 있는 시크릿 키를 의미한다.

 

 

구현 예시

○ application.yml

- jwt 토큰 암복호화를 위한 키와 유효기간 설정

 

jwt:
 accessSecretKey: aG91Mjctc2ltcGxlLXNwcmluZy1ib290LWFwaS1qd3QK
 accessTokenExpireTime: 300000

 

 

 

○ build.gradle

- jwt 관련 라이브러리

- 0.11.5 버전은 java 8이상을 지원

 

dependencies {
    // jjwt 라이브러리의 API 인터페이스를 제공
    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    
    // jjwt 라이브러리의 구현체를 제공
    implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
    
    // Jackson JSON 라이브러리와 통합하여 JWT를 직렬화하고 역직렬화하기 위한 클래스를 제공
    implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}

 

 

 

○ TokenDTO.java

- JWT 토큰 관련 DTO

 

package com.example.demo.club.dto;

import lombok.Data;
/**
 * jwt 토큰 관련 DTO
 */
@Data
public class TokenDTO {
    private String accessToken;
    private long expireTime;
    
    public TokenDTO(String accessToken){
        this.accessToken = accessToken;
    }
}

 

 

 

○ SecurityConfig.java

- 이전에 작성한 스프링 시큐리티 글의 SecurityConfig와 거의 동일하지만 다음 4가지 설정이 추가됐다.

 

1. sessionCreationPolicy(SessionCreationPolicy.STATELESS) : 스프링 시큐리티에서 인증 및 권한 부여에 대한 모든 작업이 세션 없이 수행되도록 설정

2. accessDeniedHandler(jwtAccessDeniedHandler) : 권한이 없는 사용자가 접근할 때 처리할 핸들러를 등록

3. authenticationEntryPoint(jwtAuthenticationEntryPoint) : 인증 실패한 사용자가 접근할 때 처리할 핸들러를 등록

4. apply(new JwtSecurityConfig(jwtTokenProvider)) : JwtSecurityConfig 클래스에서 정의한 JWT 인증 방식 설정을 HttpSecurity 객체에 적용

 

package com.example.demo.club.security;

import com.example.demo.club.security.jwt.JwtTokenProvider;
import com.example.demo.club.security.jwt.JwtAccessDeniedHandler;
import com.example.demo.club.security.jwt.JwtAuthenticationEntryPoint;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
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.configuration.WebSecurityCustomizer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@EnableWebSecurity
@RequiredArgsConstructor
@Slf4j
public class SecurityConfig {
    private final CustomUserDetailService customUserDetailService;
    private final JwtTokenProvider jwtTokenProvider;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfiguration) throws Exception {
        return authConfiguration.getAuthenticationManager();
    }

    /**
     *  정적 리소스에 대한 보안을 설정(이미지, 자바스크립트, CSS 등)
     */
    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() throws Exception {
       return (web) -> web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
    }

    /**
     *  인증에 사용될 DaoAuthenticationProvider를 반환
     */
    @Bean
    public DaoAuthenticationProvider daoAuthenticationProvider() {
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setPasswordEncoder(getPasswordEncoder());
        daoAuthenticationProvider.setUserDetailsService(customUserDetailService);
        return daoAuthenticationProvider;
    }

    /**
     *  비밀번호 암호화
     */
    @Bean
    public BCryptPasswordEncoder  getPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
        http.httpBasic().disable()
                // 세션을 사용하지 않음
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                 // 인증없이 접근을 허용
                .antMatchers("/login").permitAll()
                .antMatchers("/logout").permitAll() 
                .anyRequest().authenticated() // 요청들에 대한 접근제한을 설정
                .and()
                .exceptionHandling()
                 // 권한 부족 예외에 대한 처리를 위한 핸들러를 설정하는 메서드
                .accessDeniedHandler(jwtAccessDeniedHandler)
                 // 인증 실패 예외에 대한 처리를 위한 핸들러를 설정하는 메서드
                .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                .and()
                // JwtSecurityConfig 클래스에서 정의한 JWT 인증 방식 설정을 HttpSecurity 객체에 적용
                .apply(new JwtSecurityConfig(jwtTokenProvider));

        return http.build();
    }
}

 

 

 

○ JwtSecurityConfig.java

-Spring Security의 인증 체인에서 요청의 인증과 권한 부여 과정에 참여

 

package com.example.demo.club.security;

import com.example.demo.club.security.jwt.JwtTokenFilter;
import com.example.demo.club.security.jwt.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.security.config.annotation.SecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

/**
 * SecurityConfigurerAdapter를 확장
 * JwtTokenProvider를 생성자로 주입받아 JwtTokenFilter를 생성히고 Security Filter Chain에 등록
 */
@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    private final JwtTokenProvider jwtTokenProvider;
    @Override
    public void configure(HttpSecurity http) throws Exception {
        JwtTokenFilter customFilter = new JwtTokenFilter(jwtTokenProvider);
        http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

 

 

 

○ JwtTokenFilter

- request 전에 사용자 토큰을 쿠키에서 받아 검증

 

package com.example.demo.club.security.jwt;

import com.example.demo.club.exception.CustomException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * request 전에 사용자 토큰을 검증한다.
 */
@Slf4j
@RequiredArgsConstructor
public class JwtTokenFilter extends OncePerRequestFilter {
    private final JwtTokenProvider jwtTokenProvider;
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // httpheader로 부터 토큰을 받는다.
        // String token = jwtTokenProvider.resolveToken(request);
        // Cookie로 부터 토큰을 받는다.
        String token = jwtTokenProvider.getJwtTokenFromCookie(request,"accessToken");

        try {
            Authentication auth = null;
            String memberId = null;
            if (jwtTokenProvider.validateToken(token,true)){ // access 토큰 인증
                auth = jwtTokenProvider.getAuthentication(token);
                // 정상 토큰이면 토큰을 통해 생성한 Authentication 객체를 SecurityContext에 저장
                SecurityContextHolder.getContext().setAuthentication(auth);
            }
        } catch (CustomException e) {
            SecurityContextHolder.clearContext();
            response.sendError(e.getHttpStatus().value(), e.getMessage());
            return;
        }

        filterChain.doFilter(request, response); // 다음 필터 체인 실행
    }
}

 

 

 

○ JwtTokenProvider

- 토큰을 생성하고 검증하는 등의 JWT 토큰 관련 함수를 포함하는 클래스

 

package com.example.demo.club.security.jwt;

import com.example.demo.club.dto.TokenDTO;
import com.example.demo.club.exception.CustomException;
import com.example.demo.club.security.CustomUserDetailService;
import io.jsonwebtoken.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import java.util.Arrays;
import java.util.Date;
import java.util.concurrent.TimeUnit;

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
	// 비밀키
    @Value("${jwt.accessSecretKey}")
    private String accessSecretKey;
    
    // 토큰유효시간
    @Value("${jwt.accessTokenExpireTime}")
    private long accessTokenExpireTime;
    
    private final CustomUserDetailService userDetailService;

    /**
     * 적절한 설정을 통해 토큰을 생성하여 반환
     * @param authentication
     * @return
     */
    public TokenDTO generateToken(Authentication authentication) {
        Date now = new Date();
        
        //Access Token
        String accessToken = doGenerateAccessToken(authentication.getName());

        TokenDTO token = new TokenDTO(accessToken,refreshToken);
        token.setExpireTime(now.getTime() + accessTokenExpireTime);
        return token;
    }

    /**
     * 적절한 설정을 통해 access토큰을 생성하여 반환
     * @param memberId
     * @return
     */
    public String doGenerateAccessToken(String memberId) {
        Date now =  new Date();
        Claims claims = Jwts.claims().setSubject(memberId);
        //Access Token
        return Jwts.builder()
                .setClaims(claims) // 정보 저장
                .setIssuedAt(now) // 토큰 발행 시간 정보
                .setExpiration(new Date(now.getTime() + accessTokenExpireTime)) // set Expire Time
                .signWith(SignatureAlgorithm.HS256, accessSecretKey)  // 사용할 암호화 알고리즘과
                // signature 에 들어갈 secret값 세팅
                .compact();
    }

    /**
     * http 헤더로부터 bearer 토큰을 가져옴.
     * @param request
     * @return
     */
    public String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }

    /**
     * 쿠키로 부터 JWT 토큰을 가져옴.
     * @param request
     * @return
     */
    public String getJwtTokenFromCookie(HttpServletRequest request, String type) {
        String token = Arrays.stream(request.getCookies())
                .filter(c -> c.getName().equals(type))
                .findFirst() .map(Cookie::getValue)
                .orElse(null);
        return token;
    }

    /**
     * 토큰을 검증
     * @param token
     * @return
     */
    public boolean validateToken(String token) {
        
        try {
            Jwts.parser().setSigningKey(accessSecretKey).parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("잘못된 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            log.info("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            log.info("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {
            log.info("JWT 토큰이 잘못되었습니다.");
        }
        return false;
    }

    /**
     * 토큰으로부터 User 객체를 생성하여 Authentication 객체를 반환
     * @param token
     * @return
     */
    public Authentication getAuthentication(String token) {
        String username = getSubjectFromToken(token, accessSecretKey);
        UserDetails userDetails = userDetailService.loadUserByUsername(username);
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    /**
     * 토큰으로부터 클레임을 만들고 sub(memberId)를 반환
     * @param token
     * @return
     */
    public String getSubjectFromToken(String token, String secretKey) {
        Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
        return claims.getBody().getSubject();
    }

    // JWT 토큰에서 expire time 값을 가져오는 메소드
    public long getExpirationDateFromToken(String token) {
        try {
            final Claims claims = Jwts.parser().parseClaimsJws(token).getBody();
            return claims.getExpiration().getTime();
        }catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("잘못된 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            log.info("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            log.info("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {
            log.info("JWT 토큰이 잘못되었습니다.");
        }
        return 0;
    }
}

 

 

 

○ CustomUserDetailService

- 입력한 id에 맞는 사용자가 존재하는지 확인

 

package com.example.demo.club.security;

import com.example.demo.club.domain.Member;
import com.example.demo.club.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class CustomUserDetailService implements UserDetailsService {
    private final MemberRepository memberRepository;

   /**
     * 사용자 정보 확인
     * @param username
     * @return
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Member member = memberRepository.findByMemberId(username)
                .orElseThrow(() -> new UsernameNotFoundException("등록되지 않은 사용자 입니다."));
		
        return User.builder()
                .username(member.getMemberId())
                .password(member.getPassword())
                .roles(member.getRole().name())
                .build();
    }
}

 

 

 

○ Memberservice

- 로그인 시 호출되는 메소드로 토큰을 생성하도 httpHeader에 토큰을 추가한다.

 

package com.example.demo.club.service;

import com.example.demo.club.dto.TokenDTO;
import com.example.demo.club.exception.CustomException;
import com.example.demo.club.security.jwt.JwtTokenProvider;
import org.modelmapper.ModelMapper;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.example.demo.club.dto.MemberDTO;
import com.example.demo.club.repository.MemberRepository;

import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService implements UserDetailsService {
    private final MemberRepository memberRepository;
    private final ModelMapper modelMapper;
    private final JwtTokenProvider jwtTokenProvider;
    private final AuthenticationManager authenticationManager;

    public ResponseEntity<TokenDTO> signIn(MemberDTO member) {
        try {
            Authentication authentication = authenticationManager.authenticate(
                    new UsernamePasswordAuthenticationToken(
                            member.getMemberId(),
                            member.getPassword()
                    )
            );

            TokenDTO tokenDto = jwtTokenProvider.generateToken(authentication);

            // http header에 토큰을 추가한다.
            HttpHeaders httpHeaders = new HttpHeaders();
            httpHeaders.add("Authorization", "Bearer " + tokenDto.getAccessToken());

            return new ResponseEntity<>(tokenDto, httpHeaders, HttpStatus.OK);
        } catch (AuthenticationException e) {
            throw new CustomException("입력하신 정보와 일치한 사용자가 없습니다", HttpStatus.UNPROCESSABLE_ENTITY);
        }
    }
}
반응형

+ Recent posts