티스토리 뷰

Username, Password를 이용한 검증은 완료된 상태로 다음 수행될 successfulAuthenticaiton() 메서드에서 JWT토큰을 발급해보도록 하겠다.

JwtAuthenticaitnoFilter 클래스

@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

   private final AuthenticationManager authenticationManager;

   // login 요청을 하면 로그인 시도를 위해서 실행되는 함수
   @Override
   public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
      log.info("로그인 시도 : JwtAuthenticationFilter.attemptAuthentication");
      // ~
      // 검증 완료
   }

   // attemptAuthentication() 실행 후 인증이 정상적으로 완료되면 실행된다.
   // 따라서, 여기서 JWT 토큰을 만들어서 request 요청한 사용자에게 JWT 토큰을 response 해준다.
   @Override
   protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
      log.info("인증 완료 : JwtAuthenticationFilter.successfulAuthentication");
   }
}

이전에 생성해서 반환된 Authentication 객체가 authResult Parameter로 들어오고 있다. 해당 Auth객체를 이용해서 토큰을 생성해본다.

PrincipalDetails principal = (PrincipalDetails) authResult.getPrincipal();
  • Principal을 꺼낸다.

JWT 토큰을 만드는건 gradle에 추가한 [ implementation 'com.auth0:java-jwt:3.19.2' ] 이 라이브러리를 통해 쉽게 생성할 수 있다.

String jwt = JWT.create()
        .withSubject("JWT_토큰")
        .withExpiresAt(new Date(System.currentTimeMillis() + 6000 * 10)) // 만료시간 10m
        .withClaim("id", principal.getMember().getSeq()) // 회원 구분용 seq
        .withClaim("username", principal.getMember().getUsername()) // 회원 구분용 id
        .sign(Algorithm.HMAC512("SecretKey@@!!!")); // signature를 생성하기 위한 SecretKey
  • jwt가 생성되었고 이를 응답헤더에 추가해서 반환해준다.
  • 클라이언트는 전달받은 jwt 토큰을 가지고 요청 때 마다 토큰을 가지고 요청하면된다.

응답헤더에 HTTP Bearer 방식이므로 Authorizaiton : "Bearer _jwt_"를 추가한다. key-value 쌍으로 들어간다. (주의) Bearer 다음에 공백이 꼭 들어가야한다.

response.addHeader("Authorization", "Bearer " + jwt); // jwt 응답 헤더에 추가
  • 응답 메시지 Header에 jwt가 추가된다.

요청 & 응답 Body

응답 Header

body에는 딱히 내용이 없고 Header에 정상적으로 Authorization - jwt토큰이 담겨있는것을 확인할 수 있다.

해당 토큰을 해싱해보면

이런식으로 header에는 해시기법, payload에 저장된 값, signature를 확인할 수 있다. 전 포스팅에서 정리했듯이 검증은
HASH{ 인코딩(jwt_header) + . + 인코딩(jwt_payload) + . + 서버_SecretKey } == jwt_Signature 여부를 확인해서 이뤄진다.


BasicAuthenticationFilter를 이용한 JWT 검증

근데, 여기서 JWT 인증 방식과 Session 인증 방식의 차이점이 또 존재한다.

Session 검증은

  • username&passowrd LOGIN -> 서버에서 세션ID 생성 -> 클라이언트에서 쿠키에 세션ID를 저장 -> 요청 때마다 쿠키값에 세션ID를 들고 서버에 요청 -> 서버에서 세션ID 유효여부 검증

과 같은 방식으로 이뤄진다. 이때 서버에서 세션ID 유효여부 검증HttpSession에서 제공하는 session.getAttribute("세션키")를 통해 내부적으로 알아서 이뤄지고 값을 가져올 수 있다.

 

반면, 현재 작성한 JWT 로직은

  • username&passowrd LOGIN 요청 -> Authentication 생성 후 검증 -> 검증 성공 시 Session 저장소에 저장 -> Authentication으로 JWT토큰 발급 후 응답 헤더에 넣어서 반환

까지 이뤄지고 있다. 클라이언트가 요청 때 전달한 JWT토큰에 대해 서버가 JWT 토큰이 유효한지 판단하는 부분이 없다. 따라서 해당 필터를 만들어줘야 한다.

이를 위해 인증 or 권한이 필요한 부분에서 실행되는 시큐리티 필터 중 BasicAuthenticationFilter를 상속하여 필터를 구현해본다.

BasicAuthenticationFilter를 상속한 JwtAuthorizationFilter 클래스

// 권한이나 인증이 필요한 특정 주소를 요청했을 때 BasicAuthenticationFilter를 타게 된다.
// 권한이나 인증이 필요하지 않다면 거치지 않는다.
@Slf4j
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {

	private MemberRepository memberRepository;

	public JwtAuthorizationFilter(AuthenticationManager authenticationManager, MemberRepository memberRepository) {
		super(authenticationManager);
		this.memberRepository = memberRepository;
	}

	// 인증이나 권한이 필요한 주소요청이 있을 때 해당 필터를 거친다.
	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
		log.info("CHECK JWT : JwtAuthorizationFilter.doFilterInternal");
		// 1. 권한이나 인증이 필요한 요청이 전달됨

		// 2. Header 확인

		// 3. JWT 토큰을 검증해서 정상적인 사용자인지 확인

		// 서명이 정상적으로 됨
		if (username != null) {
			// 4. 정상적인 서명이 검증되었으므로 username으로 회원을 조회한다.

			// 5. jwt 토큰 서명을 통해서 서명이 정상이면 Authentication 객체를 만들어준다.

			// 6. 강제로 시큐리티_세션에 접근하여 Authentication 객체를 저장해준다.

			chain.doFilter(request, response);
		}
	}
}
  • 위 주석들을 따라 하나씩 진행해보자.

1. 권한이나 인증이 필요한 요청이 전달되었다.

void doFilterInternal() {
    log.info("1. 권한이나 인증이 필요한 요청이 전달됨");
    log.info("CHECK JWT : JwtAuthorizationFilter.doFilterInternal");
    String jwtHeader = request.getHeader("Authorization"); // Header에 들어있는 Authorization을 꺼낸다.
    log.info("jwt Header : " + jwtHeader);
    log.info("============================================================================\n");
}

요청 시 권한이 필요한 곳 목록 _ 스프링 시큐리티 Config 클래스에 작성

권한이 필요한 곳에 GET 요청

  • 권한 및 인증이 필요한 곳으로 요청이 오자 BasicAuthenticationFilter를 상속한 JwtAthorizationFilter 클래스가 호출되는 것을 확인할 수 있다.

2. Header 검증

{
    ...
    // 2. Header 확인
    log.info("2. Header 확인");
    if (jwtHeader == null || !jwtHeader.startsWith("Bearer")) {
        chain.doFilter(request, response);
        return;
    }
    log.info("============================================================================\n");
}
  • Header가 비어있거나, 비어있지 않지만 Bearer 방식이 아니라면 반환시킨다.

3. JWT 토큰을 검증해서 정상적인 사용자인지, 권한이 맞는지 확인

{    
    // JWT 토큰을 검증해서 정상적인 사용자인지, 권한이 맞는지 확인
    log.info("3. JWT 토큰을 검증해서 정상적인 사용자인지, 권한이 맞는지 확인");
    String jwtToken = request.getHeader("Authorization").replace("Bearer ", "");
    String username = null;
    try {
        username = JWT
                .require(Algorithm.HMAC512("SecretKey@@!!!"))
                .build()
                .verify(jwtToken)
                .getClaim("username")
                .asString();
    } catch (Exception e) {
        throw new BussinessException(ExMessage.JWT_ERROR_FORMAT);
    }
    log.info("============================================================================\n");
}
  • JWT 라이브러리를 이용해서 검증을 진행해본다.
  • 적용했던 Hash 알고리즘으로 SecretKey를 해시하고, 전달받은 token을 검증한다.
  • 토큰에서 username 키에 해당하는 value를 문자열로 꺼낸다.

잘못된 토큰으로 요청 & 응답

요청 & 응답
로그

정상 토큰으로 요청 & 응답

요청 & 응답
로그

4. 서명이 정상적으로 됨

JWT 토큰 검증이 제대로 진행되었다면 Signauture는 우리가 한 JWT임이 검증된것이다. 꺼낸 username을 가지고 Athentication 객체에 넣기위한 UserDetails 객체를 생성한다.

if (username != null) {
    // 서명이 정상적으로 됨
    log.info("서명이 정상적으로 됨");

    // 4. 정상적인 서명이 검증되었으므로 username으로 회원을 조회한다.
    log.info("4. 서명이 검증되었다.");
    log.info("Athentication 생성을 위해 username으로 회원 조회 후 PricipalDetails 객체로 감싼다.");
    Member member = memberRepository.findByUsername(username)
            .orElseThrow(() -> new BussinessException(ExMessage.MEMBER_ERROR_NOT_FOUND));
    PrincipalDetails principalDetails = new PrincipalDetails(member);
    log.info("============================================================================\n");
}
  • JWT의 paload에서 꺼낸 username값으로 회원을 조회한다.
  • 해당 회원을 PrincipalDetails 객체로 감싼다.

응답

5. Authentication 객체를 만들어준다.

{    
    // 5. jwt 토큰 서명을 통해서 서명이 정상이면 Authentication 객체를 만들어준다.
    log.info("5. Authentication 객체 생성");
    Authentication authentication =
            new UsernamePasswordAuthenticationToken(
                    principalDetails, null, principalDetails.getAuthorities()
            );
    log.info("============================================================================\n");
}
  • UsernamePasswordAuthenticationToken()을 통해 Authentication을 만들어준다.
  • 로그인 때 username, password를 이용해서 Authentication을 만들었던것과 동일하다.
    다만, Principals에 조회로 구한 PrincipalDetails 회원을 담고, Credentials는 null로 비우고, 권한을 넣어준다.
    (이미 검증이 되었으므로 비번은 따로 필요가 없기 때문)

6. 강제로 시큐리티_세션에 접근하여 Authentication 객체를 저장해준다.

{    
    // 6. 강제로 시큐리티_세션에 접근하여 Authentication 객체를 저장해준다.
    log.info("6. 시큐리티_세션에 접근하여 Authentication 객체 저장");
    SecurityContextHolder.getContext().setAuthentication(authentication);
    log.info("============================================================================\n");

    chain.doFilter(request, response);
}
  • SecurityContextHolder에 전달받은 JWT로 만든 Authenticaiton을 저장해준다. Authenticaiton에는 현재 권한이 들어있으므로 권한이 필요한 곳에 조회할 때 해당 권한을 체크해 줄 것이다.

응답


테스트

로그인 요청을 통해 JWT 발급

권한 : ROLE_USER

발급받은 JWT를 이용해서 요청

1. member

  • 정상적으로 요청/응답이 된다.

2. manager

  • 권한 부족으로 403 Forbidden이 발생한다.
  • HTTP 403 Forbidden 클라이언트 오류 상태 응답 코드는 서버에 요청이 전달되었지만, 권한 때문에 거절되었다는 것을 의미한다.

3. admin

  • 동일하게 403 Forbidden이 발생한다.

이렇게 로그인을 통해 발급받은 JWT 토큰을 가지고 요청을 보내고, 권한에 따른 접근 제어까지 확인해보았다. 참고로 설정관련 값들을 

public interface JwtProperties {
	int EXPIRATION_TIME = 1000 * 60 * 10; // 10m
	String TOKEN_PREFIX = "Bearer ";
	String HEADER_PREFIX = "Authorization";
}

이런식으로 인터페이스 클래스에 보관해서 사용하면 더욱 실수를 줄이고 효과적으로 통합관리할 수 있으니 참고하자.

시크릿 값은 Github와 같은 저장소에 올리면 안되므로 gitignore에 추가해준 properties파일에 담았다.

jwt:
  secret: 시크릿키값

 


내용 정정 사항

  • HttpSession은 STATELESS로 설정하였으므로 시류리티_세션에 저장하는 과정은 진행되지 않습니다.

SecurityContextPersistenceFilterSecurityContext를 저장, 로드, 삭제하는 등의 역할을 하는 곳입니다. 해당 필터 클래스의 doFilter를 보면

private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
        throws IOException, ServletException {
	...
    HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request, response);
    SecurityContext contextBeforeChainExecution = this.repo.loadContext(holder);
    try {
    	// (1번)
        SecurityContextHolder.setContext(contextBeforeChainExecution);
        if (contextBeforeChainExecution.getAuthentication() == null) {
            logger.debug("Set SecurityContextHolder to empty SecurityContext");
        }
        else {
            if (this.logger.isDebugEnabled()) {
                this.logger
                        .debug(LogMessage.format("Set SecurityContextHolder to %s", contextBeforeChainExecution));
            }
        }
        chain.doFilter(holder.getRequest(), holder.getResponse());
    }
    finally {
    	// (2번)
        SecurityContext contextAfterChainExecution = SecurityContextHolder.getContext();
        // Crucial removal of SecurityContextHolder contents before anything else.
        // (3번)
        SecurityContextHolder.clearContext();
        // (4번)
        this.repo.saveContext(contextAfterChainExecution, holder.getRequest(), holder.getResponse());
        request.removeAttribute(FILTER_APPLIED);
        this.logger.debug("Cleared SecurityContextHolder to complete request");
    }
}

(1번)을 시도하지만, repo는 SecurityContextRepository 인터페이스이며 HttpSession.SATELESS이므로 구현객체는  NullSecurityContextRepository 가 됩니다.

public final class NullSecurityContextRepository implements SecurityContextRepository {

	@Override
	public boolean containsContext(HttpServletRequest request) {
		return false;
	}

	@Override
	public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
		return SecurityContextHolder.createEmptyContext();
	}

	@Override
	public void saveContext(SecurityContext context, HttpServletRequest request, HttpServletResponse response) {
	}
}

해당 구현체는 어떠한 작업도 진행하지 않는것을 볼 수 있습니다. 이어서 다음 필터를 진행하게 되고, 이후 finally에서 Thread에 존재하는 SecurityContext는 (3번)에서 지워지게 됩니다. (4번)도 구현체를 보면 아무행동이 없는것을 볼 수 있습니다.

SessionManagementConfigurer 를 보면 isStateless() 체크를 통해 비어있는 시큐리티컨텍스트레포를 생성하게 되고, SecurityContextRepository의 기본 구현체인 HttpSessionSecurityContextRepository가 생성되지 않게 됩니다.

public void init(H http) {
    SecurityContextRepository securityContextRepository = http.getSharedObject(SecurityContextRepository.class);
    boolean stateless = isStateless();
    if (securityContextRepository == null) {
        if (stateless) {
            http.setSharedObject(SecurityContextRepository.class, new NullSecurityContextRepository());
        }
        else {
            HttpSessionSecurityContextRepository httpSecurityRepository = new HttpSessionSecurityContextRepository();
            httpSecurityRepository.setDisableUrlRewriting(!this.enableSessionUrlRewriting);
            httpSecurityRepository.setAllowSessionCreation(isAllowSessionCreation());
            AuthenticationTrustResolver trustResolver = http.getSharedObject(AuthenticationTrustResolver.class);
            if (trustResolver != null) {
                httpSecurityRepository.setTrustResolver(trustResolver);
            }
            http.setSharedObject(SecurityContextRepository.class, httpSecurityRepository);
        }
    }
    ...
}

isStateless()를 보면

private boolean isStateless() {
    SessionCreationPolicy sessionPolicy = getSessionCreationPolicy();
    return SessionCreationPolicy.STATELESS == sessionPolicy;
}

HttpSecurity를 설정했던 ".sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)" 이 값을 확인하는 것을 볼 수 있습니다.

따라서, JWT의 무상태를 유지하기 위해 세션을 저장하지 않게 됩니다.

 

전역객체인 SecurityContextHolder에 저장하는 부분은 SecurityContextHolderStrategy 구현체에 따라게 됩니다. 이때 기본 구현체는 ThreadLocalSecurityContextHolderStrategy입니다.

final class ThreadLocalSecurityContextHolderStrategy implements SecurityContextHolderStrategy {

	private static final ThreadLocal<SecurityContext> contextHolder = new ThreadLocal<>();

	@Override
	public void clearContext() {
		contextHolder.remove();
	}

	@Override
	public SecurityContext getContext() {
		SecurityContext ctx = contextHolder.get();
		if (ctx == null) {
			ctx = createEmptyContext();
			contextHolder.set(ctx);
		}
		return ctx;
	}

	@Override
	public void setContext(SecurityContext context) {
		Assert.notNull(context, "Only non-null SecurityContext instances are permitted");
		contextHolder.set(context);
	}

	@Override
	public SecurityContext createEmptyContext() {
		return new SecurityContextImpl();
	}

}

이런식으로 구현되어 있으며 ThreadLocal은 요청 당 쓰레드 별로 SecurityContext 가지고 처리하게 됩니다. 이렇게 authenticaton은 결국 SecurityContextHolder에 없고, 무상태로 처리가 됩니다.

HttpHeader에 있는 JWT 처리

JWT는 HttpHeader에 담겨서 요청이 넘어오게 되고 이를 처리하는 방법을 알아보겠습니다. 여기서는 2가지 방법에 대해 정리해보게습니다.

1. UsernamePasswordAuthenticationFilter를 통한 방법

UsernamePasswordAuthenticationFilter를 상속받은 CustomAuthenticationFilter에서 토큰을 통해 인증을 하게 됩니다.

@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
    log.info("인증 시도");
    ObjectMapper om = new ObjectMapper();

    try {
        LoginDto loginDto = om.readValue(request.getInputStream(), LoginDto.class);
        Authentication authentication =
                new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());
        return authenticationManager.authenticate(authentication);
    } catch (IOException e) {
        e.printStackTrace();
    }
    return null;
}

@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
    log.info("인증 성공");
    PrincipalUserDetails principal = (PrincipalUserDetails) authResult.getPrincipal();

    String accessToken = jwtService.createAccessToken(principal.getUsername());
    String refreshToken = jwtService.createRefreshToken();

    Member memberByUsername = jwtService.getMemberByUsername(principal.getUsername());
    jwtService.setRefreshTokenToUser(memberByUsername, refreshToken);

    jwtService.setResponseOfAccessToken(response, accessToken);
    jwtService.setResponseOfRefreshToken(response, refreshToken);
    jwtService.setResponseMessage(true, response, "로그인 성공");
}

@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
    log.info("인증 실패");
    ExMessage failMessage = failed.getMessage().equals(ExMessage.MEMBER_ERROR_NOT_FOUND_ENG.getMessage()) ?
            ExMessage.MEMBER_ERROR_NOT_FOUND :
            ExMessage.MEMBER_ERROR_PASSWORD;
    jwtService.setResponseMessage(false, response, "로그인 실패" + ": " + failMessage);
}

attemptAuthentication에서 먼저 authenticate를 통해 인증을 진행하고 성공/실패에 따라 적절한 메소드에서 이어서 진행하게 됩니다.

 

2. SecurityContextHolder를 이용한 방법

class JwtAuthenticationFilter(private val jwtTokenProvider: JwtTokenProvider) : GenericFilterBean() {
    override fun doFilter(request: ServletRequest?, response: ServletResponse?, chain: FilterChain?) {
        // 1. 요청으로부터 토큰을 가져오고, 유효성 검사를 해줍니다.
        // 2. 토큰의 유효성 검사가 완료되면 Security의 Authentication에 토큰을 저장해줍니다.
        jwtTokenProvider.resolveToken((request as HttpServletRequest)).let {
            if (it != null && jwtTokenProvider.validateToken(it)) {
                SecurityContextHolder.getContext().authentication = jwtTokenProvider.getAuthentication(it)
                logger.info("[IS VALID TOKEN]")
            }
        }
        chain!!.doFilter(request, response)
    }
}

class JwtTokenProvider() {
	...
    fun resolveToken(req: HttpServletRequest) = req.getHeader(securityProperties.headerString)?.run {
        securityProperties.tokenPrefix.let {
            if (startsWith(it)) substring(securityProperties.tokenPrefix.length, length)
            else null
        }
    }
}

 

header에서 jwt 헤더를 추출하고, 전역객체인 SecurityContextHolder에 authentication 객체를 저장합니다. 이후 Controller에서 해당 SecurityContext를 꺼내서 인가를 검증합니다.

@GetMapping
fun getUser(): UserDto {
    logger.info("GET USER")
    val user = SecurityContextHolder.getContext().authentication.principal
        .let { principal ->
            logger.info("principal: $principal.toString()")
            when (principal) {
                // 객체 타입이 UserPrincial인면 username을 받아옵니다.
                is UserDetailsPrincipal -> principal.username
                else -> throw InternalAuthenticationServiceException("Can not found matched User Principal")
            }.let { id -> userJpaRepository.findByUserName(id) }
        } ?: throw NotFoundException("멤버가 없음")
    return UserDto.from(user)
}

 

반응형
Comments
반응형
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday