vue + jpa project (16) - JWT 환경 구성 및 프론트 재수정 본문

프로그램/Vue.js

vue + jpa project (16) - JWT 환경 구성 및 프론트 재수정

반응형

과거에 세션을 이용하여 로그인을 관리했었다면 요즘에는 JWT 토큰을 이용하여 관리하는 것이 대세이다.

HTTP는 기본적으로 state-less를 지향하기 때문에 멀티서버를 운영하는 환경인 경우에 적합하고,

토큰 값을 유지하면서 자동로그인 등을 처리하기에는 용이하기 때문이다.

 

1. jwt 사용을 위하여 기본값을 아래 application.yml에 등록한다.

   issuer는 토큰을 구분하는 이름이 되겠고, secret-key는 토큰을 암호화하기 위한 키값에 해당한다. 

   키값은 본인이 원하는 대로 부여하되 너무 짧지 않아야 한다. 

   expiration-minutes 는 로그인에 대한 만료가 되게하는 분의 값이고, (추후 accescc_token에 대한 설정값)

   refresh-expiration-hours 는 재갱신의 만료가 되는 시간을 지정한 값이다. (추후 refresh_token에 대한 설정값)

jwt:
  issuer: vue-jpa-board
  secret-key: 1029384756!#@$#%$^%&^*&(*)1029384756!#@$#%$^%&^*&(*)1029384756!#@$#%$^%&^*&(*)1029384756!#@$#%$^%&^*&(*)
  expiration-minutes: 30
  refresh-expiration-hours: 720  #24시간*30일

 

2. JWT 실제 처리를 위한 Provider를 아래와 같이 util 패키지에 생성한다.

// JwtProvider.java
@Slf4j
@PropertySource("classpath:application.yml")
@Component
public class JwtProvider {
    
    private final SignatureAlgorithm signatureAlgorithm;
    private final byte[] accessKeySecretBytes;
    
    private final String issuer;
    private final String secretKey;
    private final long expirationMinutes;
    private final long refreshExpirationHours;
    private final long reissueLimit;

    public JwtProvider(
            @Value("${jwt.issuer}") String issuer,
            @Value("${jwt.secret-key}") String secretKey,
            @Value("${jwt.expiration-minutes}") long expirationMinutes,
            @Value("${jwt.refresh-expiration-hours}") long refreshExpirationHours) {
        
        this.signatureAlgorithm = SignatureAlgorithm.HS512;
        this.accessKeySecretBytes = DatatypeConverter.parseBase64Binary(secretKey);

        this.secretKey = secretKey;
        this.expirationMinutes = expirationMinutes;
        this.refreshExpirationHours = refreshExpirationHours;
        this.issuer = issuer;
        reissueLimit = refreshExpirationHours * 60 / expirationMinutes;    // 재발급 한도
    }
    
    // 토큰 생성
    public TokenDto createAccessToken(UserDto userInfo) {

        Date accessExprDtm = new Date(System.currentTimeMillis() + 1 * (this.expirationMinutes * 1000 * 60));
        Date refreshExprDtm = new Date(System.currentTimeMillis() + 1 * (this.refreshExpirationHours * 60 * 60 * 24));
        
        String accessToken = Jwts.builder()
                .setHeaderParam("typ", "JWT")
                .setIssuer(this.issuer)
                .setSubject(userInfo.getUserNo().toString())
                .claim("userId", userInfo.getUserId())
                .claim("userName", userInfo.getUserName())
                .claim("job", userInfo.getJob())
                .setIssuedAt(new Date())
                .setExpiration(accessExprDtm)
                .signWith(this.signatureAlgorithm, this.accessKeySecretBytes)
                .compact();

        //Refresh Token
        String refreshToken = Jwts.builder()
                .setHeaderParam("typ", "JWT")
                .setIssuer(this.issuer)
                .setSubject(userInfo.getUserNo().toString())
                .setIssuedAt(new Date())
                .setExpiration(refreshExprDtm)
                .signWith(this.signatureAlgorithm, this.accessKeySecretBytes)
                .compact();

        log.debug("########### Access token 만료일자 :: {}, Refresh Token 토큰 만료일자:: {}", accessExprDtm, refreshToken);
        
        return TokenDto.builder().accessToken(accessToken).refreshToken(refreshToken).userNo(userInfo.getUserNo()).exprDate(accessExprDtm).build();
    }
    
    // access token 재생성
    public String recreationAccessToken(UserDto userInfo){

        Date accessExprDtm = new Date(System.currentTimeMillis() + 1 * (this.expirationMinutes * 1000 * 60));
        
        String accessToken = Jwts.builder()
                .setHeaderParam("typ", "JWT")
                .setIssuer(this.issuer)
                .setSubject(userInfo.getUserNo().toString())
                .claim("userId", userInfo.getUserId())
                .claim("userName", userInfo.getUserName())
                .claim("job", userInfo.getJob())
                .setIssuedAt(new Date())
                .setExpiration(accessExprDtm)
                .signWith(this.signatureAlgorithm, this.accessKeySecretBytes)
                .compact();

        log.debug("########### Access token 다시 재생성한 :: {}, 토큰 만료일자:: {}", accessToken, accessExprDtm);
        
        return accessToken;
    }
    
    public String validateRefreshToken(String accessToken, String refreshToken){

        try {
            // 검증
            Jws<Claims> accessClaims = Jwts.parser().setSigningKey(this.accessKeySecretBytes).parseClaimsJws(accessToken);
            Jws<Claims> refreshClaims = Jwts.parser().setSigningKey(this.accessKeySecretBytes).parseClaimsJws(refreshToken);

            //refresh 토큰의 만료시간이 지나지 않았을 경우, 새로운 access 토큰을 생성합니다.
            if (!refreshClaims.getBody().getExpiration().before(new Date())) {
                
                UserDto userInfo = new UserDto();
                userInfo.setUserNo(Long.parseLong(getUserNo(accessToken)));
                userInfo.setUserId(getUserId(accessToken));
                userInfo.setUserName(getUserName(accessToken));
                userInfo.setJob(getJob(accessToken));

                return recreationAccessToken(userInfo);
            }
        }catch (Exception e) {
            log.error("토큰 정보가 유효하지 않습니다.");
            log.error(e.getMessage());
            return null;
        }

        return null;
    }

    public Jws<Claims> decodeToken(String accessToken) {
        try {
            Jws<Claims> accessClaims = Jwts.parser().setSigningKey(this.accessKeySecretBytes).parseClaimsJws(accessToken);
            
            return accessClaims;
        } catch (IllegalArgumentException e) {
            log.error("JWT claims string is empty: {}", e.getMessage());
        } catch (Exception e) {
            log.error("JWT claims error : {}", e.getMessage());
        }
        
        return null;
    }
	
    public String getUserNo(String accessToken) throws RuntimeException{
        try {
            Jws<Claims> tokenInfo = decodeToken(accessToken);
            return tokenInfo.getBody().getSubject();
        } catch (Exception e) {
            return null;
        }
    }
    
    public String getUserId(String accessToken) throws RuntimeException{
        try {
            Jws<Claims> tokenInfo = decodeToken(accessToken);
            return (String)tokenInfo.getBody().get("userId");
        } catch (Exception e) {
            return null;
        }
    }
    
    public String getUserName(String accessToken) throws RuntimeException{
        try {
            Jws<Claims> tokenInfo = decodeToken(accessToken);
            return (String)tokenInfo.getBody().get("userName");
        } catch (Exception e) {
            return null;
        }
    }
    
    public String getJob(String accessToken) throws RuntimeException{
        try {
        	Jws<Claims> tokenInfo = decodeToken(accessToken);
            return (String)tokenInfo.getBody().get("job");
        } catch (Exception e) {
            return null;
        }
    }
    
    public Date getExpDate(String accessToken) {
        try {
            Jws<Claims> tokenInfo = decodeToken(accessToken);
            return tokenInfo.getBody().getExpiration();
        } catch (Exception e) {
            return null;
        }
    }
}

여기서 JWT의 subject가 키값에 해당하는데 나의 경우에는 userNo를 기준으로 하였다.

보통 access token에 사용자 정보 등을 저장시켜서 추후에 꺼내어 볼 수 있도록 하는 경우가 많다. 

그리고 refresh token의 경우에는 단순히 expire date 를 확인하는 정도여서 정보를 저장할 필요가 없다.

추가적으로 getUserId, getUserName, getUserJob 등의 메소드를 만들어서 향후 값을 불러올 수 있도록 하였다.

 

 

잘되는지 테스트를 통해서 테스트를 해보겠다.

    ... 중략
    @Autowired
    JwtProvider jwtProvider;
    
    ... 중략
    @DisplayName("토큰정보 확인")
    @Test    
    void tokenTest(){
        
        UserDto userInfo = new UserDto();
        userInfo.setUserNo(1L);
        userInfo.setUserId("user1");
        userInfo.setUserName("사용자1");
        userInfo.setJob("j01");
        

        TokenDto token = jwtProvider.createAccessToken(userInfo);

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

        assertThat(jwtProvider.getUserName(token.getAccessToken()).toString()).isEqualTo("사용자1");
    }

정상적으로 잘 동작을 하였다.

 

3. TokenRequestFilter 를 filter 패키지아래 생성하여 Http 요청이 있을 때마다 처리가 되도록 할 것이다.

   그러기 위해서 OncePerRequestFilter 를 확장하고 메소드를 오버라이드 시켜서 구현한다.

// TokenRequestFilter.java
@Slf4j
@RequiredArgsConstructor
@Component
public class TokenRequestFilter extends OncePerRequestFilter {
    
    private final JwtProvider jwtProvider;
    private final TokenService tokenService;
    private final UserService userService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        try {
            if("/user/login".contains(request.getRequestURI())) {
                doFilter(request, response, filterChain);
            } else {
                
                String access_token = parseAccessJWT(request);
                String refresh_token = parseRefreshJWT(request);
                
                if(access_token == null || refresh_token == null) {
                    response.sendError(403);
                } else {

                    Jws<Claims> tokenInfo = jwtProvider.decodeToken(access_token);
                    if (tokenInfo != null) {
                        
                        String userNo = tokenInfo.getBody().getSubject();
                        String userId = (String) tokenInfo.getBody().get("userId");
                        
                        log.debug("userNo : " + userNo + " // userid : " + userId);

                        UserDetails loginUser = userService.loadUserByUsername(userId);
                        
                        UserDto userInfo = userService.selectUserInfo(userId);
                        
                        UsernamePasswordAuthenticationToken authenticationToken 
                            = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
                        
                        authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
                        

                        // refresh token 검증. DB에 있는 것과 동일한지 검증
                        TokenDto tokenDto = tokenService.retrieveUserJWTTokenInfo(userInfo.getUserNo());
                        if(!refresh_token.equals(tokenDto.getRefreshToken()) ) {
                            response.sendError(403);
                            return;
                        }

                        // 검증 후, refresh token이 만료가 안되었다면 새로운 access 토큰을 생성합니다.
                        String reAccessToken = jwtProvider.validateRefreshToken(access_token, refresh_token);
                        
                        if(reAccessToken != null) {
                            
                            response.setHeader("access_token", reAccessToken);
                            response.setHeader("test_string", "hahahahaha"); // 테스트용임
                        } else {
                            log.error("### Refresh Token is not Valid");
                            response.sendError(403);
                        }
                        
                        doFilter(request, response, filterChain);
                    } else {
                        log.error("### TokenInfo is Null");
                        response.sendError(403);
                    }
                }
            }
        } catch(Exception e) {
            log.error("### Filter Exception {}", e.getMessage());
            response.sendError(403);
        }
    }
    
    private String parseAccessJWT(HttpServletRequest request) {
        String headerAuth = request.getHeader("Authorization");
        
        if(StringUtils.hasText(headerAuth) && headerAuth.startsWith("Bearer ")) {
            return headerAuth.substring(7);
        }
        return null;
    }
    
    private String parseRefreshJWT(HttpServletRequest request) {
        String headerAuth = request.getHeader("refresh_token");
        
        if(StringUtils.hasText(headerAuth) && headerAuth.startsWith("Bearer ")) {
            return headerAuth.substring(7);
        }
        return null;
    }
}

 

4. 위의 필터를 기존의 WebSecurityConfig 쪽에서 filter를 걸어서 처리가 되게끔 포함시킨다. 

// WebSecurityConfig.java
@RequiredArgsConstructor
@EnableWebSecurity
@Configuration
public class WebSecurityConfig {

    private final TokenRequestFilter tokenRequestFilter; // 추가
    ...중략
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { 
        
            http.cors()
                .and()
                .csrf().disable().authorizeRequests() 
                .anyRequest().permitAll()
                .and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .formLogin().disable()
                // 아래줄 추가됨
                .addFilterBefore(tokenRequestFilter, UsernamePasswordAuthenticationFilter.class);

          return http.build();
       }
}

 

5. 사용자 컨트롤러를 아래와 같이 생성한다.

   로그인시에 검증하는 절차를 거쳐서 토큰이 발급되고 그것을 프론트로 리턴해서 전달해주는 역할을 한다.

// UserController.java
@Slf4j
@RequiredArgsConstructor
@CrossOrigin
@RestController
@RequestMapping("/user")
public class UserController {

    private final JwtProvider jwtProvider;
    private final TokenService tokenService;
    private final UserService userService;
    private final AuthenticationManager authenticationManager;
    
    @PostMapping("/login")
    public ResponseEntity<Map<String, Object>> login(@RequestBody Map<String, String> paramMap, HttpServletRequest request)
    {
        String userId = paramMap.get("user_id");
        String userpw = paramMap.get("user_pw");
        
        UserDetails loginUser = userService.loadUserByUsername(userId);    //userId로 정보 가져오기
        
        //가져온 정보와 입력한 비밀번호로 검증
        Authentication authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(userId, userpw));
        SecurityContextHolder.getContext().setAuthentication(authentication);   // 검증 통과 후 authentication 세팅
        
        UserDto userInfo = userService.selectUserInfo(userId);
        
        // accessToken, refreshtoken 생성
        TokenDto tokenDto = jwtProvider.createAccessToken(userInfo);
        tokenDto.setIp(request.getRemoteAddr());

        // 토큰 DB 저장
        try {
            tokenService.create(tokenDto);
        } catch (Exception e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
        
        String accessToken = tokenDto.getAccessToken();
        String refresh_token = tokenDto.getRefreshToken();
        
        Map<String, Object> result = new HashMap<>();
        result.put("user_id", loginUser.getUsername());
        result.put("access_token", accessToken);
        result.put("refresh_token", refresh_token);
        result.put("user_role", loginUser.getAuthorities().stream().findFirst().get().getAuthority());

        return ResponseEntity.ok(result); 
    }

 

6. 프론트와 백엔드 간의 CORS, Header 처리 등을 위해서 WebMvcConfig.java 파일에 아래와 같이 추가가 필요하다.

// WebMvcConfig.java
... 중략
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("http://localhost:8080", "http://localhost:8081")
                .allowedMethods("*")
                .allowedHeaders("Authorization", "Content-Type", "Accept", "Origin", "refresh_token")
                // Authorization 헤더에서 access_token을 
                // refresh_token 헤더에서 refresh_token 을 전달 받을 수 있도록 허용한다.
                .exposedHeaders("access_token", "test_string")
                // client에서 header추출이 가능하도록 하기 위해 등록
                // test_string 는 테스트를 위한 것이어서 생략 가능
                .allowCredentials(true)
                .maxAge(3600);
    }
...중략

 

7. 프론트에서 LoginAPI.js 파일을 실제 백엔드 프로세스를 호출하도록 변경한다.  

// 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'
    }
  })

  /* 이 부분은 삭제
  return {
    'data' : {
      'user_id' : reqData.user_id,
      'user_token' : 'user_test_token',
      'user_role' : 'ADM',
    }
  }
  */
}
...중략

 

8. 프론트에서  util 폴더를 생성하고 그 밑에 axios.js 파일을 새로 추가한다.

// axios.js
import axios from "axios"
import store from "@/vuex/store.js"
import router from "@/router/router"

axios.requireAuth = () => (from, to, next) => {
  const access_token = localStorage.getItem('access_token')
  const refresh_token = localStorage.getItem('refresh_token')
  if (access_token && refresh_token) {

    store.state.isLogin = true
    return next()
  } // isLogin === true면 페이지 이동
  store.state.isLogin = false
  next('/login') // isLogin === false면 다시 로그인 화면으로 이동
}

axios.interceptors.request.use(function(config) {
  //store.commit('LOADING_STATUS', true)  --> 해당 줄은 이후 loding bar 관련 시 주석을 해제한다.
  
  const access_token = localStorage.getItem('access_token')
  const refresh_token = localStorage.getItem('refresh_token')
  config.headers.Authorization = "Bearer " + access_token
  config.headers.refresh_token = "Bearer " + refresh_token

  return config
})

axios.interceptors.response.use(function(config) {
  //store.commit('LOADING_STATUS', false)  --> 해당 줄은 이후 loding bar 관련 시 주석을 해제한다.

  // 새로 발급받은 액세스 토큰을 저장함
  localStorage.setItem('access_token', config.headers.access_token)
  return config
}, function (error) {
  // 응답이 에러인 경우에 미리 전처리할 수 있다.
  return Promise.reject(error);
})

export default axios

 

9. 기존 main.js에 포함 시켰던 기본 axios를 새로 추가한 axios.js로 대체한다.

// main.js
... 중략
import { createApp } from 'vue'
import App from './App.vue'
import router from './router/router.js'
import axios from './util/axios.js' // router 아래에 위치 해야함
import store from './vuex/store.js'
...중략

 

10. 기존 Loing.vue 파일에서 처리되는 부분을 아래와 같이 수정한다.

      로그인 성공 이후 메세지와 페이지 이동을 추가하였다.

// Login.vue
... 중략
methods: {
    ...mapActions(['login']),     //vuex/actions에 있는 login 함수

    async fnLogin() {       //async 함수로 변경
      if (this.user_id === '') {
        alert('ID를 입력하세요.')
        return
      }

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

      //로그인 API 호출 
      try {
        let loginResult = await this.login({user_id: this.user_id, user_pw: this.user_pw})
        if (loginResult) {alert('로그인에 성공하였습니다.'); this.goToPages()}
      } catch(err) {
        if (err.message.indexOf('Network Error') > -1) {
          alert('서버에 접속할 수 없습니다. 상태를 확인해주세요.')
        } else {
          alert('로그인 정보를 확인할 수 없습니다.')
        }
      }
    },
    goToPages() {
      this.$router.push({
        name: 'BoardList'
      })
    }
...중략

이를 통해서 최초 Login.vue 에서 async fnLogin() 함수를 호출하면 actions.js 에 정의한 login 함수를 호출하게되고,

그 내부에서 loginAPI.js의 async doLogin(userId, userPw) 를 호출하게 되고, 바로 getUserInfo(userId, userPw) 호출을 

하여 axios.post(serverUrl + '/user/login') 부분을 호출하여 백엔드를 처리하게 된다. 그 뒤로 /util/axios.js 에 정의되어 있는

interceptor를 통해서 request 요청일때와 response 응답을 받을 때마다 처리를 수행한다.

내가 봐도 누가 봐도 좀 복잡하다. ^^;;;

 

이제 모든 처리는 다 되었으니 잘 동작하는지 확인해보자

로그인 이후에 게시판으로 자동 이동이 되고 페이지가 이동 되더라도 일단 잘된다. 

 

물론 게시판 이동시에 체크로직이 없어서 잘 되는 것이겠지만 일단 이 정도라도 잘 된다면 성공한 것이다.

다음 장에서는 게시판 이동 전에 사전 체크하여 처리가 되도록 하고, 토큰이 만료가 되면 다시 로그인 페이지로

이동하도록 하겠다.

반응형

프로그램/Vue.js Related Articles

MORE

Comments