JWT 파헤치기 1편 (선 구현 후 이해) (항해일지 38, 39일차)

김형준·2022년 6월 16일
0

TIL&WIL

목록 보기
37/45
post-thumbnail

1. 전체 구현 과정


스프링 시큐리티 + JWT 설정 과정

  • ❗ 클라이언트의 로그인 과정이 아닌 실제 서버 측 개발 과정으로 정리했습니다.
  1. Member Entity Class 및 3 계층 구현
    1) Member Entity Class는 개발하려는 웹에서 필요로하는 속성(= attribute = column)을 정의합니다.

    2) 이 때 ERD 설계에 충실히 따르며 JPA 연관관계를 설정하되, 무한참조와 같은 문제점에 대한 충분한 고려가 필요합니다.

    3) 3 계층의 경우 기본적으로 Member와 관련하여 클라이언트의 요청을 처리해줄 Controller, 비즈니스 로직을 담을 Service, DB와 소통하는 Repository를 구현합니다.

  2. application.properties(yml)에 jwt secret key 숨겨두기
    1) 숨겨둔다는 의미는, 추 후 GitHub에 형상관리를 위해 Push할 경우 올라가지 않도록 git ignore에 등록하라는 뜻입니다.

    2) jwt는 secret key를 통해 암, 복호화를 진행하기에 외부에 노출될 경우 보안이 뚫린 것이라고 생각하면 좋습니다.

  3. TokenProvider 구현
    1) TokenProvider는 JWT에 관련된 암,복호화, 검증 등의 모든 로직을 담는 객체입니다.

    2) 구현할 기능은 JWT 토큰 생성, 추 후 서버측 세션 저장소에 저장해줄 usernamePasswordAuthenticationToken 생성, JWT 검증으로 총 3가지 입니다. (자세한 기능은 아래 코드 주석을 보며 이해하면 좋습니다.)

    3) 이 과정에서 사실 세션 저장소에 저장하는 과정은 쿠키, 세션 방식에 통용되던 방식입니다. JWT로 인증 / 인가 절차를 처리할 것이기 때문에 브라우저의 쿠키에 JSESSIONID를 주지 않아 불필요한 과정으로 생각될 수 있으나, SecurityContext에 저장함으로써 사용할 수 있는 편리한 기능들이 있기에 담아줍니다. (이를테면, 권한관리 / Authentication 객체로 로그인한 유저 정보 가져오기 등이 있습니다.)

  4. JwtFilter 구현
    1) 스프링 시큐리티에서 디폴트로 제공하는 UsernamePasswordAuthenticationFilter 이전에 넣어줄 필터입니다.

    2) 해당 필터를 구현함으로써 스프링 시큐리티의 기본 인증 과정을 대체합니다.

    3) 해당 필터에는 클라이언트가 보낸 Request의 Header에 담겨있는 JWT를 읽어와 검증하고, 정상이라면 Authentication에 저장하는 과정이 담겨있습니다.

    4) OncePerRequestFilter를 상속받아 Request 당 한번씩 실행됩니다.

    Filter base class that aims to guarantee a single execution per request dispatch, on any servlet container. (Docs)

  5. JwtSecurityConfig 구현
    1) SecurityConfigureAdapter를 상속받는 JwtSecurityConfig를 구현합니다.

    2) configure()를 override하여 위 4번 과정에서 구현한 JwtFilter를 생성하고, UsernamePasswordAuthenticationFilter 전에 실행됨을 지정해줍니다. (addFilterBefore)

  6. Exception 처리 구현
    1) AuthenticationEntryPoint와 AccessDeniedHandler를 상속받을 클래스를 구현합니다.

    2) 유저 정보 없이 접근 (401) / 권한 없이 접근 (403)

  7. WebSecurityConfig 클래스 구현
    1) 스프링 시큐리티의 전반적인 설정을 해주는 WebSecurityConfig를 구현합니다.

    2) 인증 없이 접근할 수 있는 url, path를 지정해줍니다.

    3) 디폴트로 세션이 생성되는 것을 막기 위해 SessionCreationPolicy.STATELESS를 명시해줍니다.

    4) .apply(new JwtSecurityConfig(tokenProvider))를 통해 5번에서 구현한 JwtSecurityConfig에 tokenProvider를 인수로 넘겨 생성해줍니다.

    Applies a SecurityConfigurerAdapter to this SecurityBuilder and invokes SecurityConfigurerAdapter.setBuilder(SecurityBuilder)

    5) 추가적으로 프론트 엔드와 협업 중이라면 (프론트와 백의 서버가 다르다면) CORS 관련 설정도 해줍니다.

    • 만약 http를 사용중이라면, 사실상 스프링 시큐리티의 기본 기능을 통한 로그인 구현과정은 많이 복잡해진다.
    • 제가 처한 상황이 위와 같았습니다. 이 글은 세션 방식을 사용하지 않는 JWT 인증 방식으로 바꾸며 참고했던 코드를 분석하며 정리하는 글입니다.
  8. 유저 정보를 불러오는 기능 모듈화
    1) 사실 유저 정보는 이미 Authentication에 저장해줬기 때문에, @AuthenticationPrincipal이나 Authentication 객체를 통해 충분히 가져올 수 있다.

    2) 하지만 다들 모듈화의 장점에 대해 알고 있을 것이라 생각합니다. (유지보수성 향상)

  9. RefreshToken Entity Class, Repository 구현
    1) JWT의 RefreshToken을 DB에 저장하기 위한 작업입니다.

    2) RefreshToken에는 유저의 정보는 저장하지 않고, 만료기한을 저장합니다.

    3) 추 후에 스프링 배치와 같이 저장된 RefreshToken을 삭제하는 과정을 추가해주면 좋습니다. (만료기한이 지나면 삭제하는 로직을 특정 시간마다 트리거(스케쥴러, 배치 키워드))

여기까지가 스프링 시큐리티와 JWT를 사용하기 위한 설정들입니다.


2. 구현 코드

1. Member Entity & 3계층은 생략합니다.

2. application.properties 과정도 생략합니다. (secret key 추가)

3. TokenProvider 구현

@Slf4j
@Component
public class TokenProvider {

    private static final String AUTHORITIES_KEY = "auth";
    private static final String BEARER_TYPE = "bearer";
    private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30;            // 30분
    private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7;  // 7일

    private final Key key;

    public TokenProvider(@Value("${jwt.secret}") String secretKey) {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    public TokenDto generateTokenDto(Authentication authentication) {
        // 권한들 가져오기
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();

        // Access Token 생성
        Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())       // payload "sub": "name"
                .claim(AUTHORITIES_KEY, authorities)        // payload "auth": "ROLE_USER"
                .setExpiration(accessTokenExpiresIn)        // payload "exp": 1516239022 (예시)
                .signWith(key, SignatureAlgorithm.HS512)    // header "alg": "HS512"
                .compact();

        // Refresh Token 생성
        // 저장 기간 7일
        String refreshToken = Jwts.builder()
                .setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();

        return TokenDto.builder()
                .grantType(BEARER_TYPE)
                .accessToken(accessToken)
                .accessTokenExpiresIn(accessTokenExpiresIn.getTime())
                .refreshToken(refreshToken)
                .build();
    }

    public Authentication getAuthentication(String accessToken) {
        // 토큰 복호화
        Claims claims = parseClaims(accessToken);

        if (claims.get(AUTHORITIES_KEY) == null) {
            throw new RuntimeException("권한 정보가 없는 토큰입니다.");
        }

        // 클레임에서 권한 정보 가져오기
        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        // UserDetails 객체를 만들어서 Authentication 리턴
        // UserDetailsImpl userDetails = new UserDetailsImpl()
        UserDetails principal = new User(claims.getSubject(), "", authorities); //UserDetails에 Member 객체를 넣으면?
        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.warn("잘못된 JWT 서명입니다.");
            throw new IllegalArgumentException("잘못된 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            log.warn("만료된 JWT 토큰입니다.");
            throw new IllegalArgumentException("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            log.warn("지원되지 않는 JWT 토큰입니다.");
            throw new IllegalArgumentException("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {
            log.warn("JWT 토큰이 잘못되었습니다.");
            throw new IllegalArgumentException("JWT 토큰이 잘못되었습니다.");
        }
    }

    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
}

4. JwtFilter 구현

@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

    public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String BEARER_PREFIX = "Bearer ";

    private final TokenProvider tokenProvider;

    // 실제 필터링 로직은 doFilterInternal 에 들어감
    // JWT 토큰의 인증 정보를 현재 쓰레드의 SecurityContext 에 저장하는 역할 수행
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        // 1. Request Header 에서 토큰을 꺼냄
        String jwt = resolveToken(request);

        // 2. validateToken 으로 토큰 유효성 검사
        // 정상 토큰이면 해당 토큰으로 Authentication 을 가져와서 SecurityContext 에 저장
        if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
            Authentication authentication = tokenProvider.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

    // Request Header 에서 토큰 정보를 꺼내오기
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

5. JwtSecurityConfig 구현

// 직접 만든 TokenProvider 와 JwtFilter 를 SecurityConfig 에 적용할 때 사용
//JwtFilter를 Security Filter 앞에 추가한다.
@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    private final TokenProvider tokenProvider;

    // TokenProvider 를 주입받아서 JwtFilter 를 통해 Security 로직에 필터를 등록
    @Override
    public void configure(HttpSecurity http) {
        JwtFilter customFilter = new JwtFilter(tokenProvider);
        http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

6. Exception 처리 구현

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
        // 유효한 자격증명을 제공하지 않고 접근하려 할때 401
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    }
}

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
        // 필요한 권한이 없이 접근하려 할때 403
        response.sendError(HttpServletResponse.SC_FORBIDDEN);
    }
}

7. 유저 정보를 불러오는 기능 모듈화

  • 이부분은 취향에 따라 사용하면 될 것 같다.
@Slf4j
public class SecurityUtil {

    private SecurityUtil() { }

    // SecurityContext 에 유저 정보가 저장되는 시점
    // Request 가 들어올 때 JwtFilter 의 doFilter 에서 저장
    public static String getCurrentMemberUsername() {
        final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if (authentication == null || authentication.getName() == null) {
            throw  new RuntimeException("Security Context 에 인증 정보가 없습니다.");
        }
        //authenticaion은 principal을 extends 받은 객체. getName() 메서드는 사용자의 이름을 넘겨주었다.
        //String type의 username (유저의 id)
        return authentication.getName();
    }
}

8. RefreshToken Entity Class, Repository 구현

@Getter
@NoArgsConstructor
@Table(name = "refresh_token")
@Entity
public class RefreshToken {

    @Id
    @Column(name = "rt_key")
    private String key;

    @Column(name = "rt_value")
    private String value;

    @Builder
    public RefreshToken(String key, String value) {
        this.key = key;
        this.value = value;
    }

    public RefreshToken updateValue(String token) {
        this.value = token;
        return this;
    }
}

🔗 출처


3. 코멘트

  • 사실 JWT는 스프링 시큐리티를 배우는 초반에 구현하기엔 무리가 있는 것 같다.
  • 굉장히 복잡하게 느껴져서 피하게 됐었는데, 이번에 쿠키 세션 방식의 스프링 시큐리티만 사용하여 구현하려다 보니 JWT가 천사같았다.
  • 오늘 정리한 과정은 Spring Security와 JWT를 사용하기 위한 설정 과정이다.
  • 나머지는 2편에서 다룰 예정이다.
    • 회원가입, 로그인, 재발급 관련 3계층 및 내부 로직
    • loadUserByUsername()의 호출 위치
profile
BackEnd Developer

2개의 댓글

comment-user-thumbnail
2022년 7월 12일

너무 잘봤습니다~! :-)

1개의 답글