본문 바로가기
개발/게시판 만들기

[Vue] Vue.js 게시판 만들기 13 - JWT와 필터 적용

by onethejay 2022. 6. 27.
728x90

JWT(Json Web Token) 생성

로그인 방법중 토큰을 사용한 로그인을 진행하려고 합니다.
JWT를 적용하여 토큰을 발급받고 토큰의 정보를 확인하는 방법을 적용하겠습니다.

JWT 의존성은 이전 포스팅에서 추가하였으므로 바로 사용할 수 있습니다.

먼저, 토큰을 생성하고 확인하는 메서드를 생성하겠습니다.
util 패키지를 생성하고 JwtUtil.java 파일을 생성합니다.

/* util / JwtUtil.java */

import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.DecodedJWT;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.util.Date;

@Slf4j
@Component
public class JwtUtil {

    @Value("${jwt.secret}")
    String secret;

    public String createToken(String userId, String userName) {
        Algorithm algorithm = Algorithm.HMAC256(secret);
        return JWT.create()
                .withIssuer("vue-board")
                .withClaim("userId", userId)
                .withClaim("userName", userName)
                .withIssuedAt(new Date())
                .sign(algorithm);
    }

    public DecodedJWT decodeToken(String token) {
        try {
            Algorithm algorithm = Algorithm.HMAC256(secret);
            JWTVerifier verifier = JWT.require(algorithm)
                    .withIssuer("vue-board")
                    .build();
            return verifier.verify(token);

        } catch (JWTVerificationException e) {
            log.error("JWTVerificationException: {}", e.getMessage());
        } catch (IllegalArgumentException e) {
            log.error("JWT claims string is empty: {}", e.getMessage());
        }

        return null;
    }
}

JWT를 생성할때 Secret 문자열이 필요합니다.
application.yml에 jwt.secret 을 추가합니다. (문자열은 자유롭게 작성하되 너무 짧지 않아야 합니다.)

jwt:
  secret: AAAABBBBCCCCDDDDEEEEFFFFGGGG123!@#

이어서 토큰 생성과 검증 테스트를 위한 테스트 코드를 작성하겠습니다.
jwtUtil을 통해 토큰을 생성하고 생성한 토큰에서 userName 데이터를 꺼내 비교하는 테스트 코드입니다.

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit.jupiter.SpringExtension;

import static org.assertj.core.api.Assertions.assertThat;

@ExtendWith(SpringExtension.class)
@SpringBootTest
class JwtUtilTest {

    @Autowired
    JwtUtil jwtUtil;

    @DisplayName("1. 토큰 생성 후 검증")
    @Test
    void test_1(){
        String userId = "user1";
        String userName = "사용자1";

        String token = jwtUtil.createToken(userId, userName);

        System.out.println("Token : " + token);

        assertThat(jwtUtil.decodeToken(token).getClaim("userName").asString()).isEqualTo(userName);
    }
}

TokenRequestFilter 생성

로그인 이후에 생성된 토큰은 화면에서 서버로 요청할 때 항상 있어야 합니다.
또한, 서버는 매번 받은 요청에서 토큰을 꺼내 확인해야 합니다.

매번 컨트롤러에서 토큰을 확인하는 것은 반복적인 코드가 발생하므로 필터에서 작업하도록 하겠습니다.
스프링의 필터 클래스를 사용하면 요청이 들어왔을 때 앞단에서 처리할 수 있습니다.

Util 패키지에 TokenRequestFilter 클래스를 생성합니다.
OncePerRequestFilter 클래스를 상속받고 doFilterInternal 메서드를 오버라이드합니다.

import com.auth0.jwt.interfaces.DecodedJWT;
import com.example.vuebackboard.services.UserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Slf4j
@RequiredArgsConstructor
@Component
public class TokenRequestFilter extends OncePerRequestFilter {
    private final UserService userService;
    private final JwtUtil jwtUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try {
            if ("/user/login".equals(request.getRequestURI())) {
                doFilter(request, response, filterChain);
            } else {
                String token = parseJwt(request);
                if (token == null) {
                    response.sendError(403);    //accessDenied
                } else {
                    DecodedJWT tokenInfo = jwtUtil.decodeToken(token);
                    if (tokenInfo != null) {
                        String userId = tokenInfo.getClaim("userId").asString();
                        UserDetails loginUser = userService.loadUserByUsername(userId);
                        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                                loginUser, null, loginUser.getAuthorities()
                        );

                        authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                        SecurityContextHolder.getContext().setAuthentication(authentication);
                        doFilter(request, response, filterChain);

                    } else {
                        log.error("### TokenInfo is Null");
                    }
                }
            }
        } catch (Exception e) {
            log.error("### Filter Exception {}", e.getMessage());
        }
    }

    private String parseJwt(HttpServletRequest request) {
        String headerAuth = request.getHeader("Authorization");
        if (StringUtils.hasText(headerAuth) && headerAuth.startsWith("Bearer ")) {
            return headerAuth.substring(7, headerAuth.length());
        }
        return null;
    }
}

위에서 생성한 필터 클래스를 WebSecurityConfig의 addFilterBefore에 추가하여 완성합니다.

import com.example.vuebackboard.services.UserService;
import com.example.vuebackboard.util.TokenRequestFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@RequiredArgsConstructor
@EnableWebSecurity
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    private final UserService userService;
    private final TokenRequestFilter tokenRequestFilter;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    public void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userService).passwordEncoder(passwordEncoder());
    }

    public void configure(HttpSecurity http) throws Exception {
        http.csrf().disable().authorizeRequests() // 토큰을 활용하는 경우 모든 요청에 대해 접근이 가능하도록 함
                .anyRequest().permitAll()
                .and() // 토큰을 활용하면 세션이 필요 없으므로 STATELESS로 설정하여 Session을 사용하지 않는다.
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and() // form 기반의 로그인에 대해 비활성화 한다.
                .formLogin()
                .disable()
                .addFilterBefore(tokenRequestFilter, UsernamePasswordAuthenticationFilter.class);

        http.cors();
    }

}

유저 컨트롤러 생성

이제 유저 컨트롤러를 생성하고 로그인 메서드를 구현하겠습니다.

/* web / UserController.java */
import com.example.vuebackboard.services.UserService;
import com.example.vuebackboard.util.JwtUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;

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

@Slf4j
@RequiredArgsConstructor
@CrossOrigin
@RestController
@RequestMapping("/user")
public class UserController {
    private final JwtUtil jwtUtil;
    private final UserService userService;
    private final AuthenticationManager authenticationManager;

    @PostMapping("/login")
    public ResponseEntity<Map<String, Object>> login(@RequestBody Map<String, String> paramMap) {
        String userId = paramMap.get("user_id");
        String userPw = paramMap.get("user_pw");

        UserDetails loginUser = userService.loadUserByUsername(userId); //userId로 정보 가져오기

        Authentication authentication = authenticationManager.authenticate(     //가져온 정보와 입력한 비밀번호로 검증
                new UsernamePasswordAuthenticationToken(loginUser, userPw)
        );

        SecurityContextHolder.getContext().setAuthentication(authentication);   // 검증 통과 후 authentication 세팅

        String accessToken = jwtUtil.createToken(loginUser.getUsername(), loginUser.getUsername());     //accessToken 생성

        Map<String, Object> result = new HashMap<>();
        result.put("user_id", loginUser.getUsername());
        result.put("user_token", accessToken);
        result.put("user_role", loginUser.getAuthorities().stream().findFirst().get().getAuthority());

        return ResponseEntity.ok(result);
    }
}

이어서 테스트 컨트롤러를 생성합니다.

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit.jupiter.SpringExtension;
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.setup.MockMvcBuilders;

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.status;

@ExtendWith(SpringExtension.class)
@SpringBootTest
class UserControllerTest {

    @Autowired
    UserController userController;

    private MockMvc mockMvc;

    @BeforeEach
    public void setUp() {
        mockMvc = MockMvcBuilders.standaloneSetup(userController).build();
    }

    @DisplayName("1. 로그인 실패 테스트")
    @Test
    void test_1() throws Exception {
        JSONObject jsonObject = new JSONObject();
        jsonObject.put("user_id", "test_userr");
        jsonObject.put("user_pw", "test_passwordd");

        ResultActions result = mockMvc.perform(post("/user/login")
                .content(jsonObject.toString())
                .contentType(MediaType.APPLICATION_JSON));

        MvcResult mvcResult = result.andDo(print())
                .andExpect(status().isOk())
                .andReturn();

        System.out.println(mvcResult.getResponse().getContentAsString());
    }

    @DisplayName("2. 로그인 성공 테스트")
    @Test
    void test_2() throws Exception {
        JSONObject jsonObject = new JSONObject();
        jsonObject.put("user_id", "test_user");
        jsonObject.put("user_pw", "test_password");

        ResultActions result = mockMvc.perform(post("/user/login")
                .content(jsonObject.toString())
                .contentType(MediaType.APPLICATION_JSON));

        MvcResult mvcResult = result.andDo(print())
                .andExpect(status().isOk())
                .andReturn();

        System.out.println(mvcResult.getResponse().getContentAsString());
    }

}

UserRepositoryTest에서 추가했던 샘플 사용자의 정보로 로그인을 처리하는 컨트롤러를 호출합니다.

아이디 혹은 비밀번호를 틀리면 더이상 진행이 되지 않습니다.


정확히 입력되었다면 토큰 정보가 포함되어 있는 데이터를 받습니다.


화면에서 로그인 요청 후 성공하였다면 위의 데이터를 받아 header에 토큰값을 추가하면 됩니다.

화면에서 로그인 호출

이제 화면에서 서버의 로그인 API를 호출하고 성공 여부에 따라 accessToken을 확인해보겠습니다.

먼저 service 폴더의 loginAPI.js 파일의 getUserInfo 함수 내용을 수정합니다.

/* service/loginAPI.js */
import axios from 'axios'

const getUserInfo = (userId, userPw) => {
  const reqData = {
    'user_id': userId,
    'user_pw': userPw
  }

  let serverUrl = '//localhost:8081'

  return axios.post(serverUrl + '/user/login', reqData, {
    headers: {
      'Content-type': 'application/json'
    }
  })
}

export default {
  async doLogin(userId, userPw) {
    try {
      const getUserInfoPromise = getUserInfo(userId, userPw)
      const [userInfoResponse] = await Promise.all([getUserInfoPromise])
      if (userInfoResponse.data.length === 0) {
        return 'notFound'
      } else {
        localStorage.setItem('user_token', userInfoResponse.data.user_token)
        localStorage.setItem('user_role', userInfoResponse.data.user_role)
        return userInfoResponse
      }
    } catch (err) {
      console.error(err)
    }
  }
}

수정이 완료되었으면 frontboard 서버를 시작하고 로그인을 시도합니다.

먼저, 틀린 로그인 정보로 로그인을 시도해보겠습니다.


HTTP 403 에러 코드를 응답 받았습니다. 403은 access denied, 접근이 금지 되었음을 나타내는 HTTP 코드입니다. (https://developer.mozilla.org/ko/docs/Web/HTTP/Status)

이어서 샘플 사용자의 올바른 아이디와 비밀번호로 로그인을 시도합니다.

로그인 결과가 true로 나타났습니다. 그러나 화면에서 사용자의 토큰은 확인할 수 없습니다.

이전에 loginAPI.js에서 로그인에 성공하였다면 localStorage에 해당 값을 저장하도록 구현했었습니다.

웹 브라우저의 개발자 모드에서 localStorage를 입력하여 token과 role이 입력되어 있는지 확인해보도록 합니다.

이제 로그인에 성공하면 게시판 화면으로 자동으로 이동하도록 Login.vue의 script를 아래처럼 수정합니다.

<script>
import {mapActions, mapGetters} from 'vuex'

export default {
  data() {
    return {
      user_id: '',
      user_pw: ''
    }
  },
  methods: {
    ...mapActions(['login']),

    async fnLogin() {
      if (this.user_id === '') {
        alert('ID를 입력하세요.')
        return
      }

      if (this.user_pw === '') {
        alert('비밀번호를 입력하세요.')
        return
      }

      try {
        let loginResult = await this.login({user_id: this.user_id, user_pw: this.user_pw})
        if (loginResult) this.goToPages()
      } catch (err) {
        if (err.message.indexOf('Network Error') > -1) {
          alert('서버에 접속할 수 없습니다. 상태를 확인해주세요.')
        } else {
          alert('로그인 정보를 확인할 수 없습니다.')
        }
      }
    },
    goToPages() {
      this.$router.push({
        name: 'List'
      })
    }
  },
  computed: {
    ...mapGetters({
      errorState: 'getErrorState'
    })
  }
}
</script>

정리

  • 이번 포스팅을 통해 JWT를 이용하여 로그인 성공시 세션 설정이 아닌 토큰을 생성하고 해당 토큰이 유효한지 확인해봤습니다.
  • 매번 요청의 앞단에서 토큰 유무를 확인하는 필터를 생성했습니다.
  • 유저 컨트롤러를 생성하고 로그인 성공시 토큰과 권한 정보를 반환하는 로그인 메서드를 구현하였습니다.
  • 마지막으로 화면에서 서버로 로그인을 시도하여 성공시 localStorage에 저장된 데이터를 확인했습니다.

다음 포스팅에서는 로그인 성공 이후 발급된 토큰을 Header에 저장하여 호출하는 방법을 알아보도록 하겠습니다.

728x90

댓글