Spring security/02.JWT활용

JWT를 이용한 인증 처리 3

  • -

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

소스 작성

 

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

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

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