Spring boot/기본 정리

JWT를 이용한 인증, 권한 관리

코딩딩코 2022. 11. 2. 21:49

JWT를 이용하는 로직을 연습하고 싶어서 강사님께 많이 여쭈어보고 인터넷에도 검색하며 공부를 하였는데..

사용하시는 분마다 구현 로직이 달라서 어떤 틀을 가지고 구현을 해야 할지 갈피를 못 잡고 있었습니다.

우선은 JWT를 이용해서 인증과 권한 체크를 자유롭게 이용해서 원하는 데이터를 받을 수 있게끔 구현해보라고

강사님께서 조언을 해주셨습니다.

 

그래서 많은 시도를 해보다가 구현을 했습니다.

하지만 이게 올바른 방법이다 라고 말할 수 없는 부분이고 많은 허점이 있지만 그래도 JWT의 인증 방식을 이해하고

나름대로 내 입맛에 맞게끔 구현한 것에 만족하며 더욱 공부가 필요한 부분이라고 생각이 듭니다.

 

 

JWT?

Cookie 와 Session은 서버 저장소에 Key Value의 형태로 저장됩니다.

그렇기 때문에 서버의 부하가 생긴다는 단점이 있습니다.

 

JWT는 위의 문제를 해결하기 위한 방법입니다. JWT는 토큰 내부에 정보를 담을 수 있고 그 토큰을 암호화하는 방식입니다. 그리고 암호화된 토큰을 디코딩하는 과정에서 유효한 토큰인지 아닌지를 판별하며 필요시 토큰 내부에 담아놓았던 정보를 파싱 해서 가지고 올 수 있습니다.

 

JWT 구조

 

JWT에는 3가지의 구조로 나누어져있습니다.

Header. Payload. Signature

 

Header에는 해싱 알고리즘과 JWT 정보가 들어갑니다.

{
	"alg": "HS256",
	"typ": JWT
}

 

Payload에는 전송할 데이터가 들어갑니다.

정보의 한 덩어리를 클레임(claim)이라고 부르며, 클레임은 key-value의 한 쌍으로 이루어져 있습니다.
클레임의 종류는 세 종류로 나눌 수 있습니다.

Claim의 3가지 종류

  • Registrered Claim - 토큰에 대한 정보를 담기 위한 클레임들이며, 이미 이름이 등록되어있는 클레임
    • iss: 토큰 발급자(issuer)
    • sub: 토큰 제목(subject)
    • aud: 토큰 대상자(audience)
    • exp: 토큰 말료 시간(expiration)
    • nbf: 토큰 활성일(not before) - 해당 시간이 지나기 전까지는 토큰이 활성화되지 않습니다.
    • iat: 토큰 발급 시간(issued at) - 토큰 발급 시간으로부터의 경과 시간을 확인할 수 있습니다.
    • jti: JWT 토큰 식별자(JWT ID) - 중복 방지를 위하여 사용하며, 일회용 토큰(Access Token)등에 사용됩니다.
  • Public Claim - 말 그대로 공개된 클레임, 충돌을 방지할 수 있는 이름을 가져야 하며, 보통 이름을 URI로 짓습니다.
{
	"https://dhmk47.tistory.com": true
}
  • Private Claim - 클라이언트 - 서버 간에 통신을 위해 사용되는 클레임 / 숨기고 싶은 정보들을 넣어주면 됩니다.
{
	"userId" : "1234"
}

 

Signature에는 비밀키를 포함한 토큰 암호화 및 검증할 때 사용할 정보가 들어갑니다.

서명 부분에서 Header의 값과 Payload의 값을 합친 후 비밀키로 해쉬 해서 생성해 줍니다.

 

 

하지만..

사실 JWT를 사용하는 목적 중 하나가 REST의 특징 중 하나인 Stateless에 가깝게 하기 위해 사용을 한다고 하지만

강사님과 얘기를 해보고 직접 사용을 해보니 저의 부족함 때문인지 Cookie나 Session을 사용하지 않고서는 구현을 못 할 것 같다는 생각이 듭니다.

 

Session을 사용하지 않으려고 JWT를 사용하지만 JWT를 사용하려고 하니 Session을 사용해야 한다는 것이 아이러니합니다.

 

 

구현 로직

 

간단하게 페이지를 만들었습니다.

/main요청은 모두가 접근 가능한 페이지이고

/user는 인증된 사용자만이 접근 가능한 페이지

/admin은 관리자 권한을 가지고 있는 사용자만이 접근 가능한 페이지로 가정합니다.

 

그리고 Ajax 요청 또한 인증된 유저만이 요청할 수 있는 Ajax 요청 버튼을 만들었습니다.

로그인을 하지 않았을 때 /user 요청 시

 

마찬가지로 로그인을 하지 않았을때 /admin 요청 시

 

 

로그인을 하지 않았을때 인증된 유저만이 접근 가능한 Ajax 요청을 했을 시

요청 주소는 임의로 /api/v1/auth/test로 지정을 하였고 에러 코드는 403 권한 오류를 보냈습니다.

@PostMapping("/login")
public ResponseEntity<?> signInUser(@RequestBody SignInUserRequestDto signInUserRequestDto, HttpServletRequest request, HttpServletResponse response) {
    TokenResponse tokenResponse = null;
    String accessToken = null;
    String refreshToken = null;

    try {
        tokenResponse = authService.signInUser(signInUserRequestDto);

        accessToken = tokenResponse.getAccessToken();
        refreshToken = tokenResponse.getRefreshToken();
    } catch (Exception e) {
        e.printStackTrace();
        return ResponseEntity.internalServerError().body(new CustomResponseDto<>(1, "signup failed", false));
    }

//        response.setHeader(JwtProperties.ACCESS_TOKEN, JwtProperties.TOKEN_PREFIX + accessToken);
//        response.setHeader(JwtProperties.REFRESH_TOKEN, JwtProperties.TOKEN_PREFIX + refreshToken);
    sessionJwtTokenProvider.saveAccessTokenInSession(request.getSession(), accessToken);
    sessionJwtTokenProvider.saveRefreshTokenInSession(request.getSession(), refreshToken);


    return ResponseEntity.ok().body(new CustomResponseDto<>(1, "signup successfully", true));
}

 

@Override
public TokenResponse signInUser(SignInUserRequestDto signInUserRequestDto) throws Exception {
    User userEntity = null;
    String accessToken = null;
    String refreshToken = null;

    userEntity = userRepository.findUserByUserId(signInUserRequestDto.getUser_id());

    if(!bCryptPasswordEncoder.matches(signInUserRequestDto.getUser_password(), userEntity.getUser_password())) {
        throw new Exception("비밀번호가 일치하지 않습니다.");
    }

    // userId로 JWT 생성
    accessToken = jwtTokenProvider.createAccessToken(userEntity.getUser_id());
    refreshToken = jwtTokenProvider.createRefreshToken(userEntity.getUser_id());

    return TokenResponse.builder()
            .accessToken(accessToken)
            .refreshToken(refreshToken)
            .build();
}

 

@RequiredArgsConstructor
@Component
@Getter
public class JwtTokenProvider {

    private String secretKey = "secretKey";
    private String refreshKey = "refreshKey";

    private final long ACCESS_TOKEN_VALID_TIME = 1 * 60 * 1000L;        // 1분
    private final long REFRESH_TOKEN_VALID_TIME = 60 * 60 * 24 * 7 * 1000L; // 7일

    // @PostConstruct는 의존성 주입이 이루어진 후 초기화를 수행하는 메서드
    @PostConstruct
    protected void init() {     // secretKey를 Base64로 인코딩
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
        refreshKey = Base64.getEncoder().encodeToString(refreshKey.getBytes());
    }

    public String createAccessToken(String userId) {
        Date date = new Date();

        return Jwts.builder()
                .setHeaderParam(Header.TYPE, Header.JWT_TYPE)
                .setIssuer("test")
                .setIssuedAt(date)
                .setExpiration(new Date(date.getTime() + ACCESS_TOKEN_VALID_TIME))
                .claim("userId", userId)
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();
    }

    public String createRefreshToken(String userId) {
        Date date = new Date();

        return Jwts.builder()
                .setHeaderParam(Header.TYPE, Header.JWT_TYPE)
                .setIssuer("test")
                .setIssuedAt(date)
                .setExpiration(new Date(date.getTime() + REFRESH_TOKEN_VALID_TIME))
                .claim("userId", userId)
                .signWith(SignatureAlgorithm.HS256, refreshKey)
                .compact();
    }

    public String resolveAccessToken(HttpServletRequest request) {
        return request.getHeader(JwtProperties.ACCESS_TOKEN);
    }

    public String resolveRefreshToken(HttpServletRequest request) {
        return request.getHeader(JwtProperties.REFRESH_TOKEN);
    }

    public Claims getClaimsByAccessToken(String token) {
        return Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(token.replaceAll(JwtProperties.TOKEN_PREFIX, ""))
                .getBody();
    }

    public Claims getClaimsByRefreshToken(String token) {
        return Jwts.parser()
                .setSigningKey(refreshKey)
                .parseClaimsJws(token.replaceAll(JwtProperties.TOKEN_PREFIX, ""))
                .getBody();
    }

    public boolean isValidAccessToken(String token) {
        try {
            Claims accessClaims = getClaimsByAccessToken(token);
            return true;
        } catch (ExpiredJwtException exception) {
            return false;
        } catch (JwtException exception) {
            exception.printStackTrace();
            return false;
        } catch (NullPointerException exception) {
            return false;
        }
    }

    public boolean isValidRefreshToken(String token) {
        try {
            Claims refreshClaims = getClaimsByRefreshToken(token);
            return true;
        } catch (ExpiredJwtException exception) {
            return false;
        } catch (JwtException exception) {
            return false;
        } catch (NullPointerException exception) {
            return false;
        }
    }

    public boolean isOnlyExpiredToken(String token) {
        try {
            Claims accessClaims = getClaimsByAccessToken(token);
            return false;
        } catch (ExpiredJwtException exception) {
            return true;
        } catch (JwtException exception) {
            return false;
        } catch (NullPointerException exception) {
            return false;
        }
    }
}
@Component
public class SessionJwtTokenProvider {

    public void saveAccessTokenInSession(HttpSession session, String accessToken) {
        session.setAttribute(JwtProperties.ACCESS_TOKEN, JwtProperties.TOKEN_PREFIX + accessToken);
    }

    public void saveRefreshTokenInSession(HttpSession session, String refreshToken) {
        session.setAttribute(JwtProperties.REFRESH_TOKEN, JwtProperties.TOKEN_PREFIX + refreshToken);
    }

    public String loadAccessTokenInSession(HttpSession session) {
        return (String) session.getAttribute(JwtProperties.ACCESS_TOKEN);
    }

    public String loadRefreshTokenInSession(HttpSession session) {
        return (String) session.getAttribute(JwtProperties.REFRESH_TOKEN);
    }
}

로그인을 성공하게 되면 userId를 Private Claim으로 담아두고 Access Token과 Refresh Token을 발급하고

Session에 저장합니다.

 

처음에는 response header에도 각각 추가를 해주어서 Ajax 요청 시마다 request header에 넣어서 요청을 보내려고 했지만

이미 Session에 토큰을 저장하고 사용을 하기 때문에 클라이언트에게 header에 담아서 응답을 해주고

요청 시에 header에 추가해주는 과정은 불필요하다 느껴져 주석처리하였습니다.

 

 

@Configuration
@RequiredArgsConstructor
public class FilterConfig {

    private final TokenAuthorizationFilter tokenAuthorizationFilter;

    @Bean
    public FilterRegistrationBean<TokenAuthorizationFilter> tokenAuthorizationFilterFilterRegistrationBean() {
        FilterRegistrationBean<TokenAuthorizationFilter> bean = new FilterRegistrationBean<TokenAuthorizationFilter>(tokenAuthorizationFilter);
        bean.setOrder(0);
        bean.addUrlPatterns("/api/v1/auth/*", "/user", "/admin");
        return bean;
    }
}
@Slf4j
@RequiredArgsConstructor
@Component
public class TokenAuthorizationFilter implements Filter {

    private final JwtTokenProvider jwtTokenProvider;
    private final UserRepository userRepository;
    private final SessionJwtTokenProvider sessionJwtTokenProvider;

    @Override
    public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) req;
        HttpServletResponse response = (HttpServletResponse) resp;

        log.info(">>>>>>>>>>>>>>>>>> 필터");
        String uri = request.getRequestURI();
        log.info(">>>>>>>>>>>>>>>>>> {}", uri);
        String accessToken = null;
        String refreshToken = null;
        String userId = null;
        String role = null;
        String requestHeader = request.getHeader("X-Requested-With");

        if(uri.contains("/login") || uri.contains("/join")) {
            chain.doFilter(request, response);
            return;
        }

        // 세션에서 토큰 불러오기
        accessToken = sessionJwtTokenProvider.loadAccessTokenInSession(request.getSession());
        refreshToken = sessionJwtTokenProvider.loadRefreshTokenInSession(request.getSession());

        log.info(">>>>>>>>>>>>>>>> accessToken: {}", accessToken);
        log.info(">>>>>>>>>>>>>>>> refreshToken: {}", refreshToken);

        if(accessToken == null || refreshToken == null) {
            log.info(">>>>>>>>>>>>>>>>>> null!");
            sendForbiddenError(response, requestHeader);
            return;
        }

        if(uri.equals("/user") || uri.equals("/admin")) {
            role = uri.equals("/user") ? "ROLE_USER" : "ROLE_ADMIN";

        }else if(uri.contains("/auth")) {
        // 헤더에 넣어주는 방식을 사용하지 않기 때문에 주석 처리
        
//            accessToken = request.getHeader(JwtProperties.ACCESS_TOKEN);
//            refreshToken = request.getHeader(JwtProperties.REFRESH_TOKEN);

//            accessToken = accessToken.equals("null") ? null : accessToken;
//            refreshToken = refreshToken.equals("null") ? null : refreshToken;

            role = "ROLE_USER";

        }else {
            chain.doFilter(request, response);
            return;
        }

        // accessToken 유효하지 않다면
        if(!jwtTokenProvider.isValidAccessToken(accessToken)) {

            // refreshToken이 유효하다면
            if(jwtTokenProvider.isValidRefreshToken(refreshToken)) {

                // refreshToken을 파싱해서 userId를 꺼내옵니다.
                userId = (String) jwtTokenProvider.getClaimsByRefreshToken(refreshToken).get("userId");

                // 해당 userId로 다시 발급
                accessToken = jwtTokenProvider.createAccessToken(userId);
                refreshToken = jwtTokenProvider.createRefreshToken(userId);

                log.info(">>>>>>>>>>>>>>>>>>>>>>>>>>> 새로 발급 <<<<<<<<<<<<<<<<<<<<<<<<<<<");

                  // 헤더에 넣어주는 로직은 주석 처리
//                response.setHeader(JwtProperties.ACCESS_TOKEN, accessToken);
//                response.setHeader(JwtProperties.REFRESH_TOKEN, refreshToken);

                // 세션에 다시 저장
                sessionJwtTokenProvider.saveAccessTokenInSession(request.getSession(), accessToken);
                sessionJwtTokenProvider.saveRefreshTokenInSession(request.getSession(), refreshToken);

            // refreshToken도 유효하지 않다면 403에러
            }else {
                sendForbiddenError(response, requestHeader);
                return;
            }
        }

        userId = userId == null ? (String) jwtTokenProvider.getClaimsByAccessToken(accessToken).get("userId") : userId;

        User user = userRepository.findUserByUserId(userId);

        // 권한 검사
        if(!hasRole(user, role)) {
            sendForbiddenError(response, requestHeader);
            return;
        }

//		Authentication 객체에 강제로 저장하고 시큐리티가 권한 체크를 하게 하려면
//		스프링 시큐리티보다 해당 필터가 먼저 동작이 되어야 하지만
//		해당 필터는 스프링 시큐리티보다 나중에 실행되므로
//		여기서 권한체크를 해주는 것을 선택했습니다.

//        PrincipalDetails principalDetails = new PrincipalDetails(user);

//        Authentication authentication = new UsernamePasswordAuthenticationToken(principalDetails, null, principalDetails.getAuthorities());

//        SecurityContextHolder.getContext().setAuthentication(authentication);

        log.info(">>>>>>>>>>>>>>>>>>>>>>>>>>> 권한 체크 완료 <<<<<<<<<<<<<<<<<<<<<<<<<<<");
        chain.doFilter(request, response);

    }

    private void sendForbiddenError(HttpServletResponse response, String requestHeader) throws IOException {
        if(requestHeader == null) {
            // Ajax 요청이 아니라면 에러 메세지 print
            response.getWriter().print(getForbiddenErrorMessage(response));

        }else if(isAjaxRequest(requestHeader)) {
            // Ajax 요청시 403에러 전달
            response.sendError(HttpServletResponse.SC_FORBIDDEN);

        }
    }

    private boolean isAjaxRequest(String requestHeader) {
        return requestHeader.equals("XMLHttpRequest");
    }

    private String getForbiddenErrorMessage(HttpServletResponse response) throws IOException {
        StringBuilder stringBuilder = new StringBuilder();

        response.setCharacterEncoding("UTF-8");
        response.setContentType("text/html; charset=UTF-8");

        stringBuilder.append("<html><body><script>");
        stringBuilder.append("alert(\'접근 권한이 없습니다.\');");
        stringBuilder.append("location.replace(\'/main\');");
        stringBuilder.append("</script></body></html>");

        return stringBuilder.toString();
    }

    private boolean hasRole(User user, String role) {
        boolean status = false;

        if(user.getUserRoles().contains(role)) {
            status = true;
        }

        return status;
    }
}

설명은 주석을 참고해주세요.

 

 

 

로그인 성공 후 인증이 필요한 요청을 하면 위의 Filter가 작동하게 되고 세션에 저장되어있는 JWT를 이용해서 유효성 검사를 하고 유효하다면 권한 검사를 해서 403 에러 또는 chain.doFilter()를 타게 합니다.

$.ajax({
    async: false,
    type: "put",
    url: `/api/v1/auth/test`,
    contentType: "application/json",
    data: JSON.stringify({
        "test_id": "id",
        "test_password": "password"
    }),
    dataType: "json",
    success: (response) => {
        if(response.data) {
            alert("성공");
        }else {
            alert("실패");
        }
    },
    error: (request, status, error) => {
        if(request.status == 403) {
            alert("접근 권한이 없습니다.");
            location.replace("/main");
        }else {
            alert("요청 실패");
            console.log(request.status);
            console.log(request.responseText);
            console.log(error);

        }
    }
});

Ajax 요청 로직입니다.

403 에러를 받으면 다른 접근 권한이 없다는 alert창을 띄우게 됩니다.

 

인증 성공 시에는 200 코드를 받아서 정상적으로 요청이 들어갑니다.

 

 

이것을 구현하는데 시행착오가 많이 있었고 더 좋은 방법을 생각하다 보니 시간이 조금 걸렸습니다.

구현한 로직에서는 시큐리티에서 권한 검사를 하지 않습니다.

권한 검사를 시큐리티에게 맡기려면 Authenticatoin 객체에 유저 정보를 담아서 SecurityContextHolder라는

세션에 저장을 하면 시큐리티가 권한 검사를 해줍니다.

 

하지만 JWT를 사용하는 이유 중 하나인 Stateless를 위해서 사용을 하기 때문에 SecurityContextHolder 세션에

저장하는 방법 말고 다른 방법을 생각하고 있었습니다.

 

Interceptor를 이용해서 구현을 하려고 했지만 Request -> Filter -> DispatcherServlet -> Interceptor -> Controller

순으로 DispatcherServlet를 거치고 Interceptor가 낚아채기 때문에 Filter에서 처리하는 것이 더 맞다고 생각해서

Filter로 구현을 했습니다.

 

AOP로도 잘 만들어 놓으면 어노테이션만 달아놓으면서 권한 및 인증 관리를 유용하게 사용 가능할 것 같습니다.

 

🙇‍♂️

부족한 부분이 많습니다.

어떠한 피드백도 달게 받겠습니다.