[서버개발캠프] 인증 서버와 API Gateway 연결하기 1

Sieun Sim·2020년 1월 24일
1

서버개발캠프

목록 보기
12/21

기본 단일 인증 서버에 작성했던 filter와 parser들이 역할 분배가 불분명하고 서로 맞물려서 중복되는 등 더러워서 참고만 하고 새로 깔끔하게 작성해보기로 했다.

JWT Parser를 만들기 위한 준비

먼저 파싱과 검사를 위한 util 클래스를 만들 것이다.

jsonwebtoken을 사용하기 위해 먼저 pom.xml에 디펜던시를 추가한다.

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

JWT payload에 나중에 토큰 파싱만으로 기능 서버에 연결해줄 수 있는 최소한의 정보만을 담으려면 어떤 것을 담아야 할까. 먼저 서버에서 인증할 때도 필요하고 파싱만으로도 클라이언트에 유저 정보를 알려줄 때도 필요한 username이 필요하다. 그 다음으로 gateway 단에서 파싱만으로 role을 확인해 기능 서버 각각에 적절한 role이 있는 지 확인할 수 있도록 권한까지 payload에 넣을 것이다.

JWT Parse+Validator Class 만들기

Gateway에서 검사할 때는 User DB에 전혀 접근하지 않도록 parser만을 이용할 것이다. 간단하게 jwt string을 받아 payload에서 유저이름, 역할, 만료여부를 직접 꺼내 쓸 수 있는 parser와 이 parser를 이용해 validation해주는 메소드를 작성했다. Exception은 주로 만료됐거나 signature 문제일 것 같아서 둘만 따로 처리했다.

package com.quadcore.gateway.jwt;
import io.jsonwebtoken.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.io.Serializable;
import java.util.*;


@Component
public class JwtValidator implements Serializable {

    private static final long serialVersionUID = -2550185165626007488L;
    @Value("${jwt.secret}")
    private String secret;
    private static final Logger logger = LoggerFactory.getLogger(JwtValidator.class);

    public Map<String, Object> getUserParseInfo(String token) {
        Claims parseInfo = Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
        Map<String, Object> result = new HashMap<>();
        //expiration date < now
        boolean isExpired = !parseInfo.getExpiration().before(new Date());
        result.put("username", parseInfo.getSubject());
        result.put("role", parseInfo.get("role", List.class));
        result.put("isExpired", isExpired);
        return result;
    }

    private boolean isValidate(String token) {
        try {
            Map<String, Object> info = getUserParseInfo(token);
        }
        // token is expired
        catch (ExpiredJwtException e) {
            logger.warn("The token is expired.");
            return false;
        }
        // signature is wrong
        catch (SignatureException e) {
            logger.warn("Signature of the token is wrong.");
            return false;
        }
        // format is wrong
        catch (MalformedJwtException | UnsupportedJwtException | IllegalArgumentException e) {
            logger.warn("The token string is wrong format.");
            return false;
        }
        return true;
    }
}

JWT+Spring Security의 문제점

그런데 Spring Security에서 인증을 받고 넘어가려면

SecurityContextHolder.getContext().setAuthentication(authencation객체) 식으로 받아야 하고, 이 authentication 객체는 보통 UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken( userDetails, null, authorities) 형태로 만들어진다는 것인데... 여기서 userDetails를 만드려면

UserDetails userDetails = User.builder().username(String.valueOf(parseInfo.get("username"))).authorities(rolesCollection).password("dummy").build(); 이런식으로 만들게 된다. 문제는 UserDetails를 이렇게 마음대로 build하려면 username, password, authorities 세 개 모두가 필수로 필요하다는 것이다. 나는 한 번 발급한 JWT에 대해 매번 DB에 접근해서 password를 받기 싫은데. 그래서 이전에는 userDetails의 비밀번호를 더미로 주고 만들었었다. 이번에 뭔가 해결책을 얻고 싶어서 스택오버플로우에 일단 질문을 올렸다. 해답을 얻기 전까지는 더미 비밀번호를 준 상태로 진행할 것 같다.

  • 내가 올린 질문

Is there any advantage using UserDetailsService of Spring Security, when setting membership with JWT?

SecurityWebFilterChain에 JWT 검사필터 추가하기

가장 먼저 Access Token과 Refresh Token을 인식하기 위해 Header를 검사해야 한다. Gateway단의 필터이므로 요청을 보내야 할 지 말 지를 철저히 따진 후에 인증 서버에는 더 이상의 조건 검사가 필요 없게 JWT 발급 요청만을 보낼 것이다. 인증 서버와 Gateway의 로직 분리를 철저히 해야 깔끔한 분리가 될 것 같다.

  • Request Filter 의 로직 틀

    @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
                throws ServletException, IOException {
    
            String jwtToken = null;
            String requestTokenHeader = request.getHeader("Authorization");
            logger.info("tokenHeader: " + requestTokenHeader);
            //가지고 있는 token이 아예 없을 때
            if (requestTokenHeader == null) {
                //login page로 보내기
                //처음부터 access token과 refresh token을 발급받도록 유도한다.
            }
    
            //Refresh token을 보냈을 때
            else if ( try to refresh) {
                //인증 서버로 refresh token을 보내서 인증을 받아온다.
    
                //Refresh token이 유효하지 않거나 만료되면
                if ( refresh token is not validate ) {
                    //login page로 보내기
                    //처음부터 access token과 refresh token을 발급받도록 유도한다.
                }
            }
    
            //가지고 있는 token이 있을
            else {
                //validate 할 때
                if (jwtValidator.isValidate(token)) {
                    //Authentication 객체를 얻어서 통과시켜줌
                    Authentication auth = getAuthentication(token);
                    SecurityContextHolder.getContext().setAuthentication(auth);
    
                    //Response Header에 username을 줌
                    Map<String, Object> info = jwtValidator.getUserParseInfo(token);
                    response.setHeader("username", (String)info.get("username"));
                }
    
                //가지고 있는 token이 expired 됐을 때
                else if ( expired ) {
                    //refresh token을 달라는 response를 보낸다.
                }
    
                //그냥 유효하지 않은 토큰일 때
                else {
                    //login page로 보내기
                    //처음부터 access token과 refresh token을 발급받도록 유도한다.
                }
    
            }
    
            chain.doFilter(request, response);
    		}

이 로직대로 잘 구현해봐야겠다.. 인증 서버에서도 토큰 Generation과 저장, logout 관리만 하도록 로직을 단순화시켜야겠다.

참고자료

SecurityContext

0개의 댓글