Spring security/02.JWT활용

JWT를 이용한 인증 처리 3

  • -

다음으로 JwtUtil을 사용할 UserService와 JwtInterceptor를 만들어보자.

이번에 작성해볼 예제의 전체적인 동작은 아래와 같다.

UserService에서는 인증된 사용자를 대상으로 /api/info를 통해서 어떤 정보를 반환하는 서비스를 한다. 이때 인증 체크를 위해서는 JwtInterceptor를 거치게 처리했다.

JWT토큰을 활용한 서버의 동작 흐름

각 단계별 동작은 아래와 같다.

  1. /jwt/login을 호출한다. 이때는 인증이 필요 없으므로 Interceptor를 거치지 않는다.
  2. UserService를 통해서 로그인 가능한 사용자인지 확인한다.
  3. 로그인 가능하다면 JWTUtil을 통해 토큰을 생성하고 반환한다.
  4. 브라우저는 전달받은 토큰을 로컬의 브라우저에 저장한다.
  5. /api/info를 호출하면서 토큰을 전달한다. 이 api는 인증이 필요하므로 Interceptor를 거친다.
  6. JwtInterceptor는 JWTUtil를 통해 토큰의 유효성을 확인한다. 만약 토큰이 유효하다면 Service를 호출하고 그렇지 않으면 바로 리턴된다.
  7. UserService를 이용해 필요한 정보를 사용한다.

 

소스 작성

 

User

먼저 사용자 정보를 가지고 다닐 DTO로 User를 생성한다.

package xyz.quietjun.jwttest.model.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User {
    private String email;
    private String pass;
    private String authToken;    // 사용자 인증 정보 토큰
    private String refreshToken; // authToken 갱신을 위한 토큰 
}

 

UserService

다음은 비지니스 로직을 가지는 UserService를 생성해보자. 서비스 부분은 간단히 작성하기 위해서 class 기반으로 바로 작성했지만 당연히 interface 기반으로 시작하는 것이 좋다.

package xyz.quietjun.jwttest.model.service;

import java.util.Date;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import xyz.quietjun.jwttest.jwt.JwtUtil;
import xyz.quietjun.jwttest.model.dto.User;

@Service
public class UserService {

	@Autowired
	private JwtUtil jwtUtil;

	/**
	 * 로그인 계정은 편의상 email은 member@quietjun.xyz, pass는 1234로 하지만
	 * 실제로는 당연히 Repository를 통해서 처리
	 * 
	 * @param email
	 * @param password
	 * @return
	 */
	public User signin(String email, String password) {
		if (email.equals("member@quietjun.xyz") && password.equals("1234")) {
			// 인증 성공 시 authToken 생성
			String authToken = jwtUtil.createAuthToken(email);
			return User.builder().email(email).authToken(authToken).build();
		} else {
			throw new RuntimeException("그런 사람은 없어요~");
		}
	}

	/**
	 * 사용할 서비스
	 * 
	 * @return
	 */
	public String getServerInfo() {
		return String.format("여기는 은서파, 지금 시각은 %s", new Date());
	}
}

 

Controller

다음은 위의 서비스를 사용하는 Controller이다. 로그인 성공 시 클라이언트 쪽으로 jwt-auth-token을 보내주고 있다는 점을 기억해주자. 나머지 부분은 크게 다를바가 없다.

package xyz.quietjun.jwttest.controller;

import java.util.HashMap;
import java.util.Map;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import lombok.extern.slf4j.Slf4j;
import xyz.quietjun.jwttest.jwt.JwtUtil;
import xyz.quietjun.jwttest.model.dto.User;
import xyz.quietjun.jwttest.model.service.UserService;

@RestController
@RequestMapping("/api")
@Slf4j
@CrossOrigin(origins = "*")
public class UserRestController {

	@Autowired
	private UserService userService;

	@Autowired
	private JwtUtil jwtService;

	@PostMapping("/user/login")
	public ResponseEntity<Map<String, Object>> login(@RequestBody User user, HttpServletResponse res, HttpServletRequest req) {
		Map<String, Object> resultMap = new HashMap<>();
		User loginUser = userService.signin(user.getEmail(), user.getPass());
		// 생성된 토큰 정보를 클라이언트에게 전달한다.
		resultMap.put("jwt-auth-token", loginUser.getAuthToken());
		// 실제로는 필요 없지만 정보 확인차 클라이언트로 보내본다.
		Map<String, Object> info = jwtService.checkAndGetClaims(loginUser.getAuthToken());
		resultMap.putAll(info);

		return new ResponseEntity<Map<String, Object>>(resultMap, HttpStatus.ACCEPTED);
	}

	@GetMapping("/info")
	public ResponseEntity<Map<String, Object>> getInfo(HttpServletRequest req) {
		Map<String, Object> resultMap = new HashMap<>();
		resultMap.put("info", userService.getServerInfo());
		return new ResponseEntity<Map<String, Object>>(resultMap, HttpStatus.ACCEPTED);
	}
}

 

UserExceptionHandler

다음은 오류가 발생했을 때 정보를 처리하기 위한 UserExceptionHandler를 작성해보자. 여기서는 ExpiredJwtException만 처리해보자.

package xyz.quietjun.jwttest.controller;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import io.jsonwebtoken.ExpiredJwtException;
import lombok.extern.slf4j.Slf4j;

@RestControllerAdvice
@Slf4j
public class UserExceptionHandler {

	@ExceptionHandler(ExpiredJwtException.class)
	public ResponseEntity<String> handleError(ExpiredJwtException e) {
		log.error("jwt token expired", e);
		return new ResponseEntity<String>(e.getMessage(), HttpStatus.UNAUTHORIZED);
	}
}

 

JwtInterceptor

다음은 Controller 호출에 앞서 토큰 정보를 검사할 Interceptor이다. 요청 헤더에서 jwt-auth-token 값을 확인해서 valid 하면 그대로 진행하고 아니면 예외 처리해버렸다. 주의할 점은 cors 처리를 위를 위한 preflight(OPTIONS) 요청에는 header에 정보를 넘겨주지 않을 것이기 때문에 OPTIONS는 바로 통과시켜 준다.

package xyz.quietjun.jwttest.interceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

import lombok.extern.slf4j.Slf4j;
import xyz.quietjun.jwttest.jwt.JwtUtil;

@Component
@Slf4j
public class JwtInterceptor implements HandlerInterceptor {

    @Autowired
    private JwtUtil jwtUtil;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
                            throws Exception {
        // preflight를 위한 OPTIONS 요청은 그냥 전달
        if(request.getMethod().equals("OPTIONS")) {
            return true;
        }
        // request의 헤더에서 jwt-auth-token으로 넘어온 녀석을 찾아본다.
        String authToken = request.getHeader("jwt-auth-token");
        log.debug("경로: {}, 토큰: {}", request.getServletPath(), authToken);
        
        if (authToken != null) {
            // 유효한 토큰이면 진행, 그렇지 않으면 예외를 발생시킨다.
            jwtUtil.checkAndGetClaims(authToken);
            return true;
        } else {
            throw new RuntimeException("인증 토큰이 없습니다.");
        }
    }
}

 

Interceptor 동작 설정

마지막으로 Interceptor의 사용을 설정해보자. @SpringBootApplication 클래스 자체가 @Configuration을 가지고 있기 때문에 WebMvcConfigurer를 implements 받고 addInterceptors 매서드를 재정의해주자.

package xyz.quietjun.jwttest;

import java.util.Arrays;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import xyz.quietjun.jwttest.interceptor.JwtInterceptor;

@SpringBootApplication
public class JwttestApplication implements WebMvcConfigurer {

    public static void main(String[] args) {
        SpringApplication.run(JwttestApplication.class, args);
    }

    @Autowired
    private JwtInterceptor jwtInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(jwtInterceptor).addPathPatterns("/api/**") // 기본 적용 경로
                .excludePathPatterns(Arrays.asList("/api/user/**"));// 적용 제외 경로
    }
}

 

동작 테스트

다음은 단위 테스트로 위 과정을 살펴보자.

package xyz.quietjun.jwttest;

import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.CoreMatchers.startsWithIgnoringCase;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import java.util.HashMap;
import java.util.Map;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;

import com.fasterxml.jackson.databind.ObjectMapper;

import io.jsonwebtoken.MalformedJwtException;
import lombok.extern.slf4j.Slf4j;
import xyz.quietjun.jwttest.jwt.JwtUtil;

@SpringBootTest
@AutoConfigureMockMvc
@Slf4j
class ControllerTest {

	@Autowired
	MockMvc mock;

	@Autowired
	JwtUtil util;

	@Test
	@DisplayName("정보를 전달해서 로그인 후 토큰을 잘 얻을 수 있는지 확인한다.")
	public void testLogin() throws Exception {
		// given
		Map<String, String> map = new HashMap<>();
		map.put("email", "member@quietjun.xyz");
		map.put("pass", "1234");
		String content = new ObjectMapper().writeValueAsString(map);

		// when
		MockHttpServletRequestBuilder reqBuilder 
             = post("/api/user/signin").contentType("application/json").content(content);
		ResultActions action = mock.perform(reqBuilder);

		// then
		action.andExpect(status().is(202))
				.andExpect(jsonPath("$.sub", equalTo("authToken")))
				.andExpect(jsonPath("$.user", equalTo(map.get("email"))));

	}

	@Test
	@DisplayName("정상적인 토큰을 전달했을 때 원하는 정보를 잘 얻을 수 있는지 확인한다.")
	public void testGetInfoSuccess() throws Exception {
		// given
		String token = util.createAuthToken("member@quietjun.xyz");

		// when
		MockHttpServletRequestBuilder reqBuilder 
             = get("/api/info").header("jwt-auth-token", token);
		ResultActions action = mock.perform(reqBuilder);

		// then
		action.andExpect(status().is(202))
				.andExpect(jsonPath("$.info", startsWithIgnoringCase("여기는 은서파, 지금 시각은 ")));
	}

	@Test
	@DisplayName("비 정상적인 토큰을 전달했을 때 예외가 발생하는지 확인한다.")
	public void testGetInfoFail() throws Exception {
		// given
		String token = "malfomed token";

		// when
		MockHttpServletRequestBuilder reqBuilder 
             = get("/api/info").header("jwt-auth-token", token);

		// then
		assertThrows(Exception.class, () -> {
			mock.perform(reqBuilder);
		});
	}
}
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.