JWT

[최종] JWT 구현하기 : 생성하고 인증하기

JANNNNNN 2024. 7. 13. 18:07

JwtAuthorizationFilter extends BasicAuthenticationFilter 생성

// 시큐리티가 filter를 가지고 있는데 그 필터중에 BasicAuthenticationFilter라는 것이 있음
// 권한이나 인증이 필요한 특정 주소를 요청했을 때 위 필터를 무조건 타게 되어 있음
// 만약에 권한이나 인증이 필요한 주소가 아니라면 이 필터를 사용하지 않음

public class JwtAuthorizationFilter extends BasicAuthenticationFilter {


    public JwtAuthorizationFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);
    }

    // 인증이나 권한이 필요한 주소 요청이 있을 때 해당 필터를 타게 됨
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        super.doFilterInternal(request, response, chain);
        System.out.println("인증이나 권한이 필요한 주소 요청 됨");

        String jwtHeader = request.getHeader("Authorization");
        System.out.println("jwtHeader = " + jwtHeader);
    }
}

SecurityConfig 수정

@Override
    protected void configure(HttpSecurity http) throws Exception {
//        http.addFilterBefore(new MyFilter3(), SecurityContextPersistenceFilter.class);
        http.csrf().disable();
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) //세션 없는 상태로 만들겠다.
        .and()
                .addFilter(corsFilter) //@CrossOrigin는 인증이 없을때만 사용 가능함!, 인증이 필요할 땐 필터에 등록해야한다
                .formLogin().disable()
                .httpBasic().disable()
                .addFilter(new JwtAuthenticationFilter(authenticationManager())) //AuthenticationManager를 파라미터로 꼭 넣어줘야함!!
                //아래 FIlter가 추가됨
                .addFilter(new JwtAuthorizationFilter(authenticationManager(), userRepository)) //AuthenticationManager를 파라미터로 꼭 넣어줘야함!!
                .authorizeRequests()
                .antMatchers("/api/v1/user/**")
                .access("hasRole('ROLE_USER') or hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')")
                .antMatchers("/api/v1/manager/**")
                .access("hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')")
                .antMatchers("/api/v1/admin/**")
                .access("hasRole('ROLE_ADMIN')")
                .anyRequest().permitAll();
    }

이제 포스트맨으로 login을 시도하고, 권한이 필요한 페이지인 '/api/v1/user/'로 접근해봅시다!

DB에 저장되어 있는 username과 password가 일치한다면, authorization에 토큰값이 넘어왔을거에요.

그러면 이 권한을 이용해 페이지를 요청해보겠습니다.

아직은 403 에러가 발생한다!

JwtAuthorizationFilter 수정

	//userRepository 추가
    public JwtAuthorizationFilter(AuthenticationManager authenticationManager, UserRepository userRepository) {
        super(authenticationManager);
        this.userRepository = userRepository;
    }

//인증이나 권한이 필요한 주소요청이 있을 떄 해당 필터를 타게 됨
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
//        super.doFilterInternal(request, response, chain);
        System.out.println("인증이나 권한이 필요한 주소 요청 됨");

        String jwtHeader = request.getHeader("Authorization");
        System.out.println("JWTHEADER : "+ jwtHeader);
        //header가 있는지 확인
        if(jwtHeader == null || !jwtHeader.startsWith("Bearer")){
            chain.doFilter(request, response);
            return;
        }
        //JWT 토큰을 검증해서 정상적인 사용자인지 확인!
        String jwtToken = request.getHeader("Authorization").replace("Bearer ", ""); //Bearer로 시작하면, 그 부분을 ""으로 대체

        //JWT토큰의 시크릿 키 값인 cos를 가지고 있으면. jwtToken을 서명한다(?)
        String username =
                JWT.require(Algorithm.HMAC512("cos")).build().verify(jwtToken).getClaim("username").asString();

        //서명이 정상적으로 됨
        if(username != null){
            System.out.println("username 정상");
            //Repository에서 username이 존재하는지 조회
            User userEntity = userRepository.findByUsername(username);

            //조회한 사용자 정보를 바탕으로 PrincipalDetails 생성
            //PrincipalDetails : Spring Security의 UserDetails 구현한 사용자 세부정보 객체
            PrincipalDetails principalDetails = new PrincipalDetails(userEntity);

            //JWT 토큰 서명을 통해서 서명이 정상이면 Authentication 객체를 만들어준다.
            //들어가는 값은 user, password, role
            Authentication authentication =
                    new UsernamePasswordAuthenticationToken(principalDetails, null, principalDetails.getAuthorities());
            //강제로 시큐리티 세션에 접근해 AUthentication 객체를 저장
            SecurityContextHolder.getContext().setAuthentication(authentication);

            chain.doFilter(request, response);
        }
    }
  1. 헤더에 있는 jwt를 String jwtToken으로 가져온다
  2. JWT토큰의 시크릿 키 값을 cos라고 설정했으므로, 알고리즘 HMAC512의 값이 cos이면 build를 시작하고, jwtToken을 값으로 넣어준다. 그리고 jwtToken 속 username을 추출해 String username에 담는다.
  3. username에 값이 정상적으로 들어왔으면(=null이 아니면) userRepository에 username이 존재하는지 조회한다.
  4. 조회한 정보 userEntity를 PrincipalDetails(UserDetails 상속)에 넣어준다.
  5. new UsernamePasswordAuthenticationToken의 인자로 PrincipalDetails, 암호(Pasword), 권한 정보를 넘겨주고 강제로 Authentication을 생성한다. 
    1. 여기에서는 토큰을 이용해 인증하는 방식이기 때문에 암호를 null값을 넣었다.
  6. 시큐리티 세션에 접근해 Authentication을 저장한다.
  7. 이후 다시 필터를 타게한다

SecurityConfig 수정

public class SecurityConfig extends WebSecurityConfigurerAdapter {
    protected final CorsFilter corsFilter;
    //IOC 추가
    private final UserRepository userRepository;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
//        http.addFilterBefore(new MyFilter3(), SecurityContextPersistenceFilter.class);
        http.csrf().disable();
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) //세션 없는 상태로 만들겠다.
        .and()
                .addFilter(corsFilter) //@CrossOrigin는 인증이 없을때만 사용 가능함!, 인증이 필요할 땐 필터에 등록해야한다
                .formLogin().disable()
                .httpBasic().disable()
                .addFilter(new JwtAuthenticationFilter(authenticationManager())) //AuthenticationManager를 파라미터로 꼭 넣어줘야함!!
                // userRepository 추가
                .addFilter(new JwtAuthorizationFilter(authenticationManager(), userRepository)) //AuthenticationManager를 파라미터로 꼭 넣어줘야함!!
 **중간 생략

RestApiController 수정

// user, manager, admin 권한 접근 가능
    @GetMapping("/api/v1/user")
    public String user(Authentication authentication) {
        PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
        System.out.println("principalDetails = " + principalDetails.getUsername());
        return "user";
    }

    // manager, admin 접근 가능
    @GetMapping("/api/v1/manager")
    public String manager() {
        return "manager";
    }

    // admin만 접근 가능
    @GetMapping("/api/v1/admin")
    public String admin() {
        return "admin";
    }

이제 다시 포스트맨으로 요청을 보낸다

NullPointerException 발생

Security Session이 생성되지 않아 발생!

에러 발생 원인

  • JwtAuthorizationFilter의 doFilterInternal에서 아래처럼 주석 처리한 부분을 주석 처리해주자. 그렇지 않으면
    super.doFilterInternal(request, response, chain); 여기서 한번
    chain.doFilter(request, response); 여기서 한번
    총 두번의 응답이 이뤄져 에러가 발생한다.
@Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        //아래 부분 주석!!
//        super.doFilterInternal(request, response, chain);
        System.out.println("인증이나 권한이 필요한 주소 요청 됨");

        ...... 중간 생략

            chain.doFilter(request, response);
        }


    }
}

수정 후 다시 요청을 보내보면 정상적으로 응답이 오는 것을 확인할 수 있다!

JwtProperties 인터페이스 생성

  • 유지보수나 확장성을 생각해서 jwt를 생성할 때 쓰이는 값을들 따로 모아 인터페이스를 생성해주자
public interface JwtProperties {
    String SECRET = "cos"; //우리 서버만 알고 있는 비밀 값
    int EXPIRATION_TIME = 60000 * 10; // 만료 시간
    String TOKEN_PREFIX = "Bearer ";
    String HEADER_STRING = "Authorization";
}

JwtAuthenticationFilter 수정

  • JwtProperties값을 사용해야 하는 부분을 수정해주자
@Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        System.out.println("successfulAuthentication이 실행됨 => 인증이 완료됨");


        PrincipalDetails principalDetails = (PrincipalDetails) authResult.getPrincipal();

        // RSA방식은 아니구 Hash암호방식
        // 이 부분 수정
        String jwtToken = JWT.create()
                .withSubject(principalDetails.getUsername())
                .withExpiresAt(new Date(System.currentTimeMillis() + JwtProperties.EXPIRATION_TIME))
                .withClaim("id", principalDetails.getUser().getId())
                .withClaim("username", principalDetails.getUser().getUsername())
                .sign(Algorithm.HMAC512(JwtProperties.SECRET));

        response.addHeader(JwtProperties.HEADER_STRING, JwtProperties.TOKEN_PREFIX+jwtToken);

        System.out.println("토큰 생성 후 헤더에 첨부 완료");
//        super.successfulAuthentication(request,response,chain,authResult);
    }

JwtAuthorizationFilter 수정

  • 마찬가지로 JwtProperties값을 사용하는 부분을 수정해주자
 @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        //아래 부분 주석!!
//        super.doFilterInternal(request, response, chain);
        System.out.println("인증이나 권한이 필요한 주소 요청 됨");

        String jwtHeader = request.getHeader(JwtProperties.HEADER_STRING);
        System.out.println("jwtHeader = " + jwtHeader);

        // header에 토큰이 있는지 확인
        if (jwtHeader == null || !jwtHeader.startsWith(JwtProperties.TOKEN_PREFIX)) {
            chain.doFilter(request, response);
            return;
        }

        // jwt를 검증해서 정상적인 사용자인지 확인
        String jwtToken = request.getHeader(JwtProperties.HEADER_STRING).replace(JwtProperties.TOKEN_PREFIX, "");

        // 암호화를 할 때 HMAC512("pem")이라고 넣었으므로 여기서도 pem이라는 단어를 이용해서 복호화 함
        String username =
                JWT.require(Algorithm.HMAC512(JwtProperties.SECRET)).build().verify(jwtToken).getClaim("username").asString();

        // 서명이 정상적으로 되었다는 뜻
        if (username != null) {
            System.out.println("username 정상");
            User userEntity = userRepository.findByUsername(username);

            PrincipalDetails principalDetails = new PrincipalDetails(userEntity);
            System.out.println("principalDetails = " + principalDetails.getUsername() + " user name이 찍여야 함");

            // Jwt 토큰 서명을 통해서 서명이 정상이면 Authentication 객체를 만들어준다.
            Authentication authentication =
                    new UsernamePasswordAuthenticationToken(principalDetails, null, principalDetails.getAuthorities());

            // 강제로 시큐리티 세션에 접근하여 Authentication 객체를 저장
            SecurityContextHolder.getContext().setAuthentication(authentication);


        }

        chain.doFilter(request, response);
    }