본문 바로가기
스프링/security

JWT의 동작원리

by 진믈리 2024. 9. 12.
저번 시간에 JWT에 대해 알아보고 예약 도메인 프로젝트에 JWT를 적용시키기로 했다. 
이제 JWT가 무엇인지 알았으니 JWT의 동작원리와 사용법을 알아보자.

이 글에서는 마이그레이션 실습을 위해 일부로 현재 사용을 권장하지 않는 기술들을 포함하고 있다.
Java11, SpringBoot 2.7.5, Spring Security 5.7.4 버전을 사용중임을 미리 알린다.
또한 현재 학습을 위한 단계로 단일토큰 방식을 구현한다.

 

2024.09.11 - [스프링/security] - 스프링 시큐리티 JWT란 무엇일까? 왜 사용할까?

JWT 인증 방식 시큐리티 동작 원리

  • 회원가입 : 회원가입 로직은 기존의 세션 방식과 JWT방식의 차이가 없다.

  • 로그인(인증) : 로그인 요청을 받은 후 세션 방식은 서버 세션이 유저 정보를 저장하지만 JWT 방식은 토큰을 생성하여 응답한다.

  • loginId 와 password를 담아 POST login 요청을 보내면
  • UsernamePasswordAuthenticationFilter가 loginId와 pssword를 꺼내 Authentcaion Manger 에 넘겨주게 된다.
  • 그 후 Authentication Manager가 DB에서 데이터를 꺼내와 검증을 거쳐 로그인에 성공하게 되면
  • Successful Authentication이 동작하게 되고 사용자에게 생성된 토큰을 응답해 준다.

SecurityConfig 클래스  설정하기 (JWT는 스테이트리스?)

JWT 방식에서는 Session을 STATELESS 방식으로 관리하게 된다. 따라서 sessionManagement 메서드에 세션 설정을 해주어야 한다.

http
        .sessionManagement()
        .sessionCreationPolicy(SessionCreationPolicy.STATELESS);

 

최신버전의 시큐리티를 사용하고 있다면 람다식을 활용하여 구현하면 되겠다.

http
        .sessionManagement((session) -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS));

 

 

UsernamePasswordAuthenticationFilter

 

스프링 공식문서를 보면 HttpSecurity 방식으로 formLogin을 실행하는 필터를 확인해 보면 UsernamePasswordAuthenticationFilter 라고 되어 있다.

 

Form 로그인 방식에서는 클라이언트단이 username과 password를 전송한 뒤 Security 필터를 통과 하는데 UsernamePasswordAuthentication 필터에서 회원 검증 진행을 시작한다. 

(회원 검증의 경우 UsernamePasswordAuthenticationFilter가 호출한 AuthenticationManager를 통해 진행하며 DB에서 조회한 데이터를 UserDetailsService를 통해 받음)

 

하지만 우리의 JWT 프로젝트는 SecurityConfig에서 formLogin 방식을 disable 했기 때문에 기본적으로 활성화되어 있는 해당 필터는 동작하지 않는다. 

따라서 로그인을 진행하기 위해서 필터를 커스텀하여 등록해야한다.

 

@RequiredArgsConstructor
public class LoginFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;

    @Override
    public Authentication attemptAuthentication(
            HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {

        String loginId = obtainUsername(request); //아이디 추출
        String password = obtainPassword(request); // 패스워드 추출

        //스프링 시큐리티에서 loginId와 password를 검증하기 위해 token에 담아야 함
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(loginId, password, null);

        //검증을 위해 토큰을 authenticationManager 전달
        return authenticationManager.authenticate(authenticationToken);
    }

    //로그인 성공시 실행하는 메서드(JWT 발급하는 장소)
    @Override
    protected void successfulAuthentication(
            HttpServletRequest request, HttpServletResponse response,
            FilterChain filterChain, Authentication authentication){

    }

    //로그인 실패시 실행하는 메서드
    @Override
    protected void unsuccessfulAuthentication(
            HttpServletRequest request, HttpServletResponse response,
            AuthenticationException authenticationException){

    }
}

 

자 이제 우리가 만든 LoginFilter를 시큐리티에 적용시키기 위해 설정을 해줘야 한다.

 

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

	//AuthenticationManager가 인자로 받을 AuthenticationConfiguraion 객체 생성자 주입
    private final AuthenticationConfiguration authenticationConfiguration;

	//AuthenticationManager Bean 등록
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .
                .
                .
                
                
        http
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS);

		//필터 추가 LoginFilter()는 인자를 받음 (AuthenticationManager() 메소드에 authenticationConfiguration 객체를 넣어야 함) 따라서 등록 필요
        http
                .addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration)),
                        UsernamePasswordAuthenticationFilter.class);
    }
}

 

addFilterAt을 통해 우리가 커스텀한 LoginFilter를 설정해 준다. 이때 LoginFilter를 생성해주기 위해 필요한 AuthenticationManager 를 Bean으로 등록해 준다. 

 

JWT 암호화

JWT 암호화 방식

  • 암호화 종류
    • 양방향
      - 대칭키 - 서명과 검증에 동일한 키를 사용하는 방식 단일 서버나 비밀키가 안전하게 공유된 상태에 적합
      - 비대칭키 - 서명에 개인키 검증에 공개키를 사용, 여러 서버나 시스템간에 검증이 필요할때 적합
    • 단반향

이 글에서는 대칭키를 구현할 것이다. 다음 글에서 refresh 사용 등과 함께 비대칭 키를 알아보자.

 

JwtUtil

JwtUtil 클래스를 생성하여 JwtUtile에서 Jwt를 검증할 메서드와 Jwt를 생성할 메서드 등을 작성 할 것이다.

 

토큰 Payload에 저장될 정보

  • loginid
  • role
  • 생성일
  • 만료일

JWTUtil 구현 메서드

  • loginId 확인 메서드
  • role 확인 메서드
  • 만료일 확인 메서드

secret key 등록

jwt:
  secret: "dlrjtdmstlzbflxldkaghzldlqslek"

실습을 위한 코드이니 시크릿 키는 임의로 application.yml에 저장해 두었다.

 

이제 저장한 시크릿 키로 JWT를 발행해 보자.

 

현재 코드는 JWT 0.11.5 버전 구현 방법이다.

@Component
public class JwtUtil {

    private Key key;

    //yml에 설정한 시크릿키를 가지고 객체 Key 생성
    public JwtUtil(@Value("${jwt.secret}")String secret) {
        byte[] byteSecretKey = Decoders.BASE64.decode(secret);
        key = Keys.hmacShaKeyFor(byteSecretKey);
    }

    //유저의 아이디를 검증
    public String getUsername(String token) {

        return Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody()
                .get("username", String.class);
    }

    //유저의 권한을 검증
    public List<String> getRoleList(String token) {

        Claims claims = Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();

        Object roles = claims.get("roleList");

        // Object를 List<String>으로 변환
        if (roles instanceof List<?>) {
            return ((List<?>) roles).stream()
                    .filter(role -> role instanceof String)
                    .map(role -> (String) role)
                    .collect(Collectors.toList());
        }

        return Collections.emptyList();
    }


    //토큰 만료시간을 검증
    public Boolean isExpired(String token) {

        return Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody()
                .getExpiration().before(new Date());
    }


    /*토큰 생성 로직

        유저의 아이디 , 유저의 권한, 토큰 유지시간 등을 인자로 받는다.

     */
    public String createJwt(String username, List<String> roleList, Long expiredMs) {

        Claims claims = Jwts.claims();
        claims.put("username", username); //페이로드에 username과 role 추가
        claims.put("roleList", roleList);

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(new Date(System.currentTimeMillis())) //토큰 생성 시간
                .setExpiration(new Date(System.currentTimeMillis() + expiredMs)) // 토큰 생성시간 + 인자로 받은 유지시간 = 토큰 만료시간
                .signWith(key, SignatureAlgorithm.HS256) //시크릿 키를 활용한 토큰 서명
                .compact(); // 토큰 발행
    }
}

 

생성자를 통해 JwtUtil 을 생성할때 미리 생성한 jwt.secret키를 불러와 객체 SecretKey 를 만들어 준다. SecretKey 객체를 통해 토큰을 생성한다.

 

  • getUsername() - 사용자의 loginId 검증 메서드
  • getRole() - 사용자의 권한 검증 메서드
  • isExpired() - 토큰의 만료시간 검증 메서드
  • createJwt() - 토큰 생성 메서드

토큰 생성 클래스 JwtUtil클래스를 생성하였으니 앞서 생성하였던 LoginFilter에서 로그인이 성공하면 실행되는 SuccessfulAuthentication() 에서 JwtUtil를 사용하기 위해 LoginFilter 클래스에 JwtUtil을 주입해주자.

@RequiredArgsConstructor를 사용하여 생성자를 통해 주입받기에 SecurityConfig 에서도 수정이 필요하다

생성자에 jwtUtil을 추가해 주자

http
        .addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil),
                UsernamePasswordAuthenticationFilter.class);

 

SuccessfulAuthentication()

@Override
    protected void successfulAuthentication(
            HttpServletRequest request, HttpServletResponse response,
            FilterChain filterChain, Authentication authentication){

        CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal();

        //CustomUserDetails에서 로그인 ID와 role 정보 가져 오기
        String loginId = customUserDetails.getUsername();
        List<String> roleList = customUserDetails.getRoleList().stream()
                .map(RoleType::name)
                .collect(Collectors.toList());

        // JWT 생성
        String token = jwtUtil.createJwt(loginId, roleList, 60*60*10L);

        // 생성된 토큰을 HTTP 응답 헤더에 포함시킴
        response.addHeader("Authorization", "Bearer " + token);
    }

 

HTTP 헤더에 토큰 포함하기

response.addHeader("Authorization", "Bearer " + token);

 

서버는 JWT 토큰을 클라이언트에 전달하기 위해 HTTP응답 헤더에 토큰을 추가한다.

클라이언트는 이 JWT토큰을 받아서 이후 요청을 할 때마다 이 토큰을 HTTP 요청의 Authorization 헤더에 포함시켜 서버로 보내게 된다.

  • Authorization 헤더
    • Authorization 헤더는 HTTP 표준 헤더 중 하나로 인증정보를 담는 헤더이다.
    • 여기서 Bearer 토큰 스킴을 사용한다.  Bearer는 '보유자 인증' 이라는 의미로 토큰을 가진 사람은 해당 권한을 가지고 있음을 나타낸다. 
  • response.addHeader() : HTTP 응답에 새로운 헤더를 추가한다.
  • "Authorization" : 추가할 헤더의 이름은 Authorzation이다.
  • "Bearer " + token : 헤더의 값이다. "Bearer " 뒤에 JWT 토큰을 붙여서 클라이언트에게 전달한다. 꼭 띄어쓰기를 주의하자!!   
  • 공식문서의 추가설명

 

UnsatisfiedDependencyException 에러 발생

기존에 시크릿 키 길이를 짧게 설정하였더니 오류가 발생했다. HMAC-SHA 알고리즘에 사용되는 키는 256비트 이상 이어야 한다.

 

 

Postman을 활용한 응답확인

 

포스트맨을 활용하여 로그인 요청을 전송해보니 응답 헤더에 Authorization 에 Bearer 시작하는 토큰 값이 헤더에 잘 포함되는것을 확인할 수 있다.

 

JWT 인증은 어디서 누가 하는걸까??

이제 로그인 시에 토큰값을 잘 응답 받는 것을 확인했다. 그러나 우리는 전달받은 토큰값을 활용하여 요청을 진행할때마다 검증을 받아야 한다. 그럼 사용자를 어떻게 인증하게 될까??

공식문서를 살펴보면 SecurityContextHolder가 인증의 핵심이라고 한다. 나는 JWT 검증로직을 구현한 JWTFilter 클래스를 구현해 주었다.

 

JWTFilter

@RequiredArgsConstructor
public class JWTFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        //request 에서 Authorization 헤더를 찾음
        String authorization = request.getHeader("Authorization");

        //토큰이 없거나 Bearer 로 시작 하지 않으면 다음 필터로 진행
        if (authorization == null || !authorization.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        //Bearer 이후의 토큰값만 추출
        String token = authorization.split(" ")[1];

        //토큰 소멸 시간 검증
        if(jwtUtil.isExpired(token)){
            filterChain.doFilter(request,response);
            return;
        }

        String username = jwtUtil.getUsername(token);
        List<String> roleList = jwtUtil.getRoleList(token);

        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
                username, null, roleList.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList())
        );

        // SecurityContext에 인증 정보 설정
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);

        // 다음 필터로 진행
        filterChain.doFilter(request, response);
    }
}

 

 

다시 아키텍처를 살펴보면 우리는 SecurityContextHolder 인증 정보를 전달하기 위해 Authentication을 설정해야한다. 그런데 위의 코드를 살펴보면 UsernamePasswordAuthenticationToken을 인자로 받고 있다. 그 이유는 아래의 사진을 살펴보자  

UsernamePasswordAuthenticationToken은 Authentication을 구현한다고 나와있다.

정말이다.

 

 

Authentication은 다음이 포함되어있다.

공식문서의 설명이다.

 

이렇게 구현한 UsernamePasswordAuthenticationToken을 포함시켜 SecutityContextHolder에 인증정보를 설정했다.

그럼 검증로직은 완성되었다.

 

그럼 생성한 JWTFilter를 SecurityConfig에서 LoginFilter 전에 추가해주자.

http
            .addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class);

 

테스트를 해보기 위해 임시 Api를 설정했다. 테스트 유저의 role은 USER이고 /admin, /user 두개의 페이지에 접속해보자

 

  • 먼저 생성된 유저를 로그인하고 HTTP에 추가된 Authorization토큰을 복사했다.

  • 그후 발행된 토큰을 가지고 /user 페이지에 접속해보니 상태코드 200과 함께 성공적으로 요청을 마쳤다.

 

  • 하지만 admin페이지에서는 권한이 없어 403상태코드가 발생하는것을 확인할 수 있다.