검색

레이블이 JWT인 게시물을 표시합니다. 모든 게시물 표시
레이블이 JWT인 게시물을 표시합니다. 모든 게시물 표시

2023년 2월 6일

코딩 - Vue 3 + Typescript + Vite + Vuetify 3.x 로 시작하는 웹 프로그램 : 반복작업

특정 화면에서 반복적인 작업을 수행해야하는 아래와 같은 경우를 가정해보자. 

  • Index.vue 는 15초를 주기로  새로운 배경 이미지를 보여 준다.

개발은 크게 고민하지 않고 공개된 Unsplash  API 을 활용 이미지 목록을 가져와서 주기적으로 화면의 배경 이미지를 바꿔주었다. 

  • src/store/unsplash.js 
  • src/view/Indes.vue

Index View 에서는 ① mounted 되면  upsplash 에서 가져온 이미지 목록이 있는지 확인하고 없는 경우 fetch 함수를 호출하여 이미지 목록을 가져온다. ② setTimeout 함수를 사용하여 15000 단위로 바탕하면 이미지를 바꿔준다.

위 코드의 문제점은 아래와 같다.

  1. 다른 View 로 이동하더라도 백그라우드에서 ② 작업이 무한 실행된다. 
  2. 다른 View 에서 Index View 로 이동하면 ① 작업을 수행하고 ② 작업을 다시 무한 실행한다.
  3.  불필요한 메모리 누수가 발생한다. 


◼︎ 개발환경 

  • Model : MacBook Pro (14-inch, 2021)
  • CPU : Apple M1 Pro 
  • MENORY : 16GB
  • DISK : 512 GB SSD
  • OS : macOS 13.2 (22D49)

문제 해결

Vue3 Lifecycle 에 따르면 Composition API 에서 setup 은 create 와 같으며 View 가 마운트 되면  (View 가 화면에 보여지면) mounted (onMounted) 가 실행되고 언마우트 되면 (다른 View 로 이동하면) unmounted (onUnmounted)가 실행되는데 이점을 이용하면 Index 가 화면에 보여질 때만 ② 작업을 수행하도록 할 수 있다. 

반복작업은  setInterval() 함수를 사용하고 더이상 반복작업이 필요없는 경우에는 (다른 View 로 이동할 때) , setInterval() 호출시 리턴되는 ID 값을 인자로 clearInterval() 함수를 호출하면 timer 을 초기활 할 수 있다. 



참고자료

코딩 - Vue 3 + Typescript + Vite + Vuetify 3.x 로 시작하는 웹 프로그램 : 로그인

 개발을 조금 쉽게 해보려고 무료로 이용이 가능한  Vuetify 3 - Vite Theme FREE 을 다운로드 하여 사용하였다.  (기본적인 환경만 미리 세팅이 되어 있어  사용할 필요성을 없을 것 같다. 타입스크립트라던지 개발 편의를 위한 설정이라든지 등의 여러 설정을 추가하느라 결과적으로는 더 힘들게 되어 버렸다.)

◼︎ 개발환경 

  • Model : MacBook Pro (14-inch, 2021)
  • CPU : Apple M1 Pro 
  • MENORY : 16GB
  • DISK : 512 GB SSD
  • OS : macOS 13.2 (22D49)

사전 준비

기본 프로젝트는 Vite Theme Free 를 일부 수정 사용하였다. 
  1. 필요없는 부분은 삭제를 하고 (src/plugins/vuetify.js) 전역으로 버튼 등의 UI 을 변경한 부분은 삭제하여 원래의 Vuetify 3 UI 을 사용할 수 있도록 변경. 
  2. 타입스크립트를 사용하기 위하여 js 파일을 ts 로 변경하고 관련 설정을 추가. 
  3. 개발 편의를 목적으로 vite-plugin-checker 플러그인을 추가. 

사용 주요 기술

① Pinia

Pinia 는 Vuejs 코어 팀이 구축한 새로운 상태 관리 라이브러리로 Vuex의 후속 제품이며 Vuex 보다 훨씬 적은 코드로 상태 관리가 가능하며 Vue 3에서 공식 권장되는 라이브러리이다.

Pinia 에서 State(데이터를 의미) 와 비즈니스 로직은 스토어(Store) 을 사용하여 정의되고, 각 스토어(Store) 에는 state, getters, actions 등이 포함한다. 
  • State - Store 에서 관리되는 데이터를 의미. 기존 Vue 구성요소 data 와 동일.
  • Getters - State 및/또는 다른 Getter 에서 파생(계산)된 값을 반환. 기존 Vue 구성요소 computed 와 동일.
  • Actions - 비즈니스 논리 또는 API 호출과 같은 비동기 작업을 실행하는 데 사용. 기존 Vue 구성요소 methods 와 동일.
Pinia 스토어는 원하는 만큼 정의하여 사용할 수 있고 Vue 애플리케이션 어디에서나 전역적으로 접근할 수 있다. (상세 내용은 https://pinia.vuejs.org/core-concepts 참조.)

② Vue Composition API

Vue 3과 함께 제공되는 새로운 Vue Composition API <script setup> 를 사용하여 컴포넌트들이 빌드되도록 하였다. 일단 필요한 부분만 기술하면 되기 때문에 코드가 아주 간격해지는 이점이 있다.  (Vue Composition API에 대한 자세한 내용은 https://vuejs.org/guide/extras/composition-api-faq.html 을 참조.)

③ Vuelidate

입력값에 대한 검사는 Vutify 3 가이드 문서를 참조하여 Vuelidate  을 사용하여 구현하였다.
npm install @vuelidate/core @vuelidate/validators --save
Vuelidate 을 사용하면 비교적 간단하게 입력값 검사 기능을 구현할 수 있다.

④ dotenv

Path: /.env

VITE(이하 비트) 개발 도구 환경에서 dotenv 파일은 서버 호출 API URL 과 같은 다양한 변수 값들을 포함할 수 있다. dotenv 에 정의되는 변수들은 반드시  VITE_ 로 시작하며 Vue 프로그램에서는 import.meta.env.<variable name> 형태로 사용할 수 있다. ( 자세한 것은 Env Variables and Modes | Vite (vitejs.dev) 참고)


VITE_API_URL=http://localhost:4000


로그인 주요 구성 요소및 구현

  • src/views/Login.vue : 로그인 UI 
  • src/components/Alert.vue : 오류 처리 콤포넌트 
  • src/store/auth.store.ts : 인증을 처리하는 스토어
  • src/store/alert.store.ts : 오류 정보를 처리하기 위한 스토어
  • src/router/index.ts : 라우터 메인 
  • src/router/accounts.router.ts : 로그인 라우터 정보 
  • src/App.vue : 메인 UI
  • src/main.ts : 메인 스크립트
로그인은 아래와 같은 로직으로 기반으로 구현된다.  

로그인 시 성공하면 Jwt 토큰과 사용자 정보를 같이 응답 받아 사용하도록 되어 있는데 이부분은 분리하는 것이 보안상 더 나은 선택인 것 같다. 

참고자료

  1. Form validation in Vue 3 in 10 minutes with Vuelidate
  2. Vue 3 + Pinia - User Registration and Login Example & Tutorial
  3. Vue.js의 공식 Store | Pinia
  4. Vue 3 Authentication with JWT, Vuex, Axios and Vue Router

2020년 7월 26일

JWT ( JSON WEB TOKEN ) 인증환경에서 이미지 다운로드

서버 세션이 아닌 JWT 토큰을 기반으로 하는 웹 프로그램은 서버와의 통신이 필요하면 HTTP 헤더에 JWT 토큰을 포함하여  요청하고 서버는 클라이언트 요청 헤더의  JWT 토큰을 꺼내어 인증 정보를 꺼네어 사용하게 된다. 이미지의 경우는 어떻까 ? 

HTML 에서 img  태그는 JWT 와 같은 보안 토큰을 헤더에 포함하는 기능을 제공하지 않는다. 그러나 이미지 데이터를 스크립트에서 다운로드하고  data : image / FILETYPE; base64 URI 체계를 사용하여 이미지를 웹 페이지에 포함시키는 방법을 사용하면 간단하게 제한을 극복할 수 있다. ( vue-auth-image 모듈이나 관련 글들을 참고하는 것이 많은 도움이 되었다.) 



다수의 이미지를 다운로드 하는 경우 요청간 지연을 주기 위하여 아래와 같은 방법을 사용했다.
  var delay = 600 ;
  $("#grid figure img[is-secure]").each(function () {
    var $this = $(this);
    delay = delay + 50 ;
    $this.parent().addClass('is-loading');
    studio.ui.setSecureImage($this, delay);
  });
  
이미지를 가져오지 못하는 경우에는 재요청을 수행하도록 코드하였다.
  
  function setSecureImage(elemment, delay, callback) {
  var delay = delay || 1000;
  let url = elemment.attr("secure-image");
  setImageAsBase64(
    url,
    function (data) {
      elemment.attr("src", data);
      elemment.parent().removeClass("is-loading");
      if (isFunction(callback)) callback();
    },
    function () {
      let maxRetry = 3,
        retry = 0;
      if (defined(elemment.data("max-retry"))) {
        maxRetry = elemment.data("max-retry");
      }
      if (defined(elemment.data("retry"))) {
        retry = elemment.data("retry");
      }
      if (retry < maxRetry) {
        elemment.data("retry", retry + 1);
        sleep(delay);
        setSecureImage(elemment);
      } else {
        elemment.parent().removeClass("is-loading");
      }
    }
  );
  }

  async function setImageAsBase64(url, successHandler, errorHandler) {
  const headers = {};
  // JWT 토큰 값 설정
  Object.assign(headers, studio.services.accounts.authHeader()); 
  await axios
    .get(url, { headers: headers, responseType: "arraybuffer" })
    .then((response) => {
      var mineType = response.headers["content-type"].toLowerCase();
      var b64encoded = Buffer.from(response.data, "binary").toString("base64");
      var prefix = "data:" + mineType + ";base64,";
      if (isFunction(successHandler)) successHandler(prefix + b64encoded);
    })
    .then((error) => {
      if (isFunction(errorHandler)) errorHandler();
    });
  } 
  



아쉽지만 이미지를 다운로드하여 data 방식으로 설정하는데 있어 오류가 발생되어 다시 시도하도록 추가한 코드는 효용성이 없는 것 같다. 테스트 환경 이슈일 수도 있으나 다운로드에 있어 무작위로 오류가 발생하며 일단 오류가 발생되면 다시 시도한 경우 역시 오류가 발생한다. (→ 서버 이슈인지 코드 이슈인지는 확실하지 않다.)

참고자료

2020년 6월 8일

Spring 기반 웹 프로그램에서 JWT 인증 사용하기

JWT ( JSON WEB TOKEN ) 는 URL 형식으로 구현할 수 있는 전자서명 기술이다. JWT 는 주요한 속성 정보를 RFC7519 표준에 따라 JSON 테이터 구조로 표현한다.

JWT 기술을 활용하면 손쉽게 클라이언트(앱) 기반 프로그램에서 토큰 기반 인증 및 REST API  호출을 구현할 수 있다.

클라이언트와 서버간의 인증 시나리오를 정리하면 아래와 같다. 

❶ 사용자가 로그인을 하면, ❷ 서버는 사용자의 정보를 기반으로한 JWT 토큰을 발급한다.
그 후, ❸ 사용자가 서버에 요청을 할 때 마다 JWT를 포함하여 전달한다. ❹ 서버는 클라이언트에서 요청을 받을때 마다, 해당 토큰이 유효하고 인증됐는지 검증을 하고, 사용자가 요청한 작업에 권한이 있는지 확인하여 작업을 처리.



Spring 기반의 서버 사이드  JWT

다행스러운 것은 JWT 토큰 생성과 검증을 위한 라이브러리가  JWT 사이트에서 다양한 언어 버전으로 제공하고 있어 적절하게 이를 활용하여 손쉽게 JWT 를 적용할 수 있다.



글에서는  "https://github.com/jwtk/jjwt" 를 사용하였다. 


서버 프로그램 개발환경이 maven 으로 구현되어 있어 아래와 같이 라이브러리 의존성을 추가하였다. 

<!--  JWT  -->

<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt -->

<dependency>

    <groupId>io.jsonwebtoken</groupId>

    <artifactId>jjwt</artifactId>

            <version>0.9.1</version>

</dependency>


서버 프로그램이 Spring 을 기반으로 하고 있어 Spring Security 설정에 JWT 처리를 위한 커스텀 필터를 구현하여 추가하였다. (커스텀 필터는 클라이언트에서 헤더에 JWT 토큰을 가지고 오는 경우 이를 검증하고 인증 정보를 보안 컨텍스트에 추가하는 역할을 수행한다.)

package architecture.community.security.spring.authentication.jwt;

import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.SignatureException;
import io.jsonwebtoken.UnsupportedJwtException;

public class JwtTokenProvider {

	private static final Logger logger = LoggerFactory.getLogger(JwtTokenProvider.class);

	private static final String AUTHORITIES_KEY = "auth";

	/**
	 * JWT Token expire time.
	 */
	static final long EXPIRATIONTIME = 864_000_000; // 10 days

	/**
	 * Secret Key string
	 */
	static final String SECRET = "ThisIsASecret";

	/**
	 * JWT Token prefix
	 */
	static final String TOKEN_PREFIX = "Bearer";

	/**
	 * Header key for JWT Token
	 */
	static final String HEADER_STRING = "Authorization";

	public String createToken(Authentication authentication) {
		String authorities = authentication.getAuthorities().stream().map(authority -> authority.getAuthority())
				.collect(Collectors.joining(","));
		ZonedDateTime now = ZonedDateTime.now();
		ZonedDateTime expirationDateTime = now.plus(EXPIRATIONTIME, ChronoUnit.MILLIS);
		Date issueDate = Date.from(now.toInstant());
		Date expirationDate = Date.from(expirationDateTime.toInstant());
		return Jwts.builder().setSubject(authentication.getName()).claim(AUTHORITIES_KEY, authorities)
				.signWith(SignatureAlgorithm.HS512, SECRET).setIssuedAt(issueDate).setExpiration(expirationDate)
				.compact();
	}

	public Authentication getAuthentication(String token, UserDetailsService userDetailsService, boolean refresh) {
		Claims claims = Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody();
		Collection authorities = Arrays
				.asList(claims.get(AUTHORITIES_KEY).toString().split(",")).stream()
				.map(authority -> new SimpleGrantedAuthority(authority)).collect(Collectors.toList());
		UserDetails details = userDetailsService.loadUserByUsername(claims.getSubject());
		return new UsernamePasswordAuthenticationToken(details, "", refresh ? details.getAuthorities() : authorities);
	}

	public Authentication getAuthentication(String token) {

		Claims claims = Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody();
		Collection authorities = Arrays
				.asList(claims.get(AUTHORITIES_KEY).toString().split(",")).stream()
				.map(authority -> new SimpleGrantedAuthority(authority)).collect(Collectors.toList());

		User principal = new User(claims.getSubject(), "", authorities);

		return new UsernamePasswordAuthenticationToken(principal, "", authorities);

	}

	public boolean validateToken(String authToken) {
		try {
			Jwts.parser().setSigningKey(SECRET).parseClaimsJws(authToken);
			return true;
		} catch (SignatureException e) {
			logger.info("Invalid JWT signature: " + e.getMessage());
			logger.debug("Exception " + e.getMessage(), e);
		} catch (MalformedJwtException e) {
			logger.error("Invalid JWT token: {}", e.getMessage());
		} catch (ExpiredJwtException e) {
			logger.error("JWT token is expired: {}", e.getMessage());
		} catch (UnsupportedJwtException e) {
			logger.error("JWT token is unsupported: {}", e.getMessage());
		} catch (IllegalArgumentException e) {
			logger.error("JWT claims string is empty: {}", e.getMessage());
		}
		return false;
	}
}

(JwtTokenProvider 사용하여 Jwt 토큰을 검증하고 userDetailsService 을 이용하여 보안 컨텍스트에 인증 객체를 설정한다.)
package architecture.community.security.spring.authentication.jwt;

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

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.util.Assert;
import org.springframework.web.filter.GenericFilterBean;

import architecture.ee.util.StringUtils;
import io.jsonwebtoken.ExpiredJwtException;

public class JWTFilter extends GenericFilterBean {

	private Logger logger = LoggerFactory.getLogger(getClass());

	private final JwtTokenProvider jwtTokenProvider;

	@Autowired(required = false)
	@Qualifier("userDetailsService")
	private UserDetailsService userDetailsService;

	public JWTFilter(JwtTokenProvider jwtTokenProvider) {
		Assert.notNull(jwtTokenProvider, "JwtTokenProvider cannot be null");
		this.jwtTokenProvider = jwtTokenProvider;
	}

	@Override
	public void afterPropertiesSet() {
		Assert.notNull(jwtTokenProvider, "JwtTokenProvider cannot be null");
	}

	public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
			throws IOException, ServletException {

		HttpServletRequest request = (HttpServletRequest) req;
		HttpServletResponse response = (HttpServletResponse) res;

		String header = request.getHeader(JwtTokenProvider.HEADER_STRING);
		if (StringUtils.isNullOrEmpty(header)) {
			chain.doFilter(request, response);
			return;
		}

		try {
			String jwt = this.resolveToken(request);
			if (!StringUtils.isNullOrEmpty(jwt)) {
				logger.debug("jwt token : {}", jwt);
				if (this.jwtTokenProvider.validateToken(jwt)) {
					Authentication authentication;
					if (userDetailsService != null) {
						authentication = this.jwtTokenProvider.getAuthentication(jwt, userDetailsService, true);
					} else {
						authentication = this.jwtTokenProvider.getAuthentication(jwt);
					}
					SecurityContextHolder.getContext().setAuthentication(authentication);
				}
			}
		} catch (ExpiredJwtException eje) {
			logger.info("Security exception for user {} - {}", eje.getClaims().getSubject(), eje.getMessage());
			response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
			logger.debug("Exception " + eje.getMessage(), eje);
			return;
		}

		chain.doFilter(request, response);
		this.resetAuthenticationAfterRequest();
	}

	private void resetAuthenticationAfterRequest() {
		logger.debug("reset authentication as null.");
		SecurityContextHolder.getContext().setAuthentication(null);
	}

	private String resolveToken(HttpServletRequest request) {
		String bearerToken = request.getHeader(JwtTokenProvider.HEADER_STRING);
		if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(JwtTokenProvider.TOKEN_PREFIX)) {
			String jwt = bearerToken.substring(7, bearerToken.length());
			return jwt;
		}
		return null;
	}
}  
  
필터를 정의하고 사용자 정의 필터를 추가해준다.

<http auto-config="true" use-expressions="true" disable-url-rewriting="true">

  <cors configuration-source-ref="corsSource"/> 

  <headers>

    <frame-options policy="SAMEORIGIN" />

  </headers>

  <intercept-url pattern="/*" access="permitAll"/>

<intercept-url pattern="/error/*" access="permitAll"/>

<intercept-url pattern="/data/**" access="permitAll"/>

<intercept-url pattern="/display/**" access="permitAll"/>

<intercept-url pattern="/accounts/**" access="permitAll"/>

<intercept-url pattern="/secure/studio/**" access="hasRole('ROLE_ADMINISTRATOR') or hasRole('ROLE_SYSTEM') or hasRole('ROLE_DEVELOPER')" />

<intercept-url pattern="/secure/data/**" access="hasRole('ROLE_USER') or hasRole('ROLE_SYSTEM')" /> 

  <!-- Form Login Page Setting -->

  <form-login login-page="/accounts/login"

username-parameter="username" 

password-parameter="password"

login-processing-url="/accounts/auth/login_check"

authentication-success-handler-ref="authenticationSuccessHandler"

authentication-failure-handler-ref="authenticationFailureHandler" />

  <http-basic />

  <custom-filter before="BASIC_AUTH_FILTER" ref="jwtFilter" />


  ...


</http>


<beans:bean id="jwtTokenProvider" class="architecture.community.security.spring.authentication.jwt.JwtTokenProvider"></beans:bean>

<beans:bean id="jwtFilter" class="architecture.community.security.spring.authentication.jwt.JWTFilter">

  <beans:constructor-arg ref="jwtTokenProvider" />

</beans:bean>



③ CORS 지원
주의할 것은 보안상 이유에서 웹 브라우져는 스크립트에서 웹페이지가 로드된 원 도메인(IP)이 아닌 타 도메인(IP) 자원을 호출하는 것을 금지하고 있다. 

CORS(Cross-origin resource sharing) 는 대부분의 웹 브라우져들이 구현하고 있는 W3C 표준이며 이를 통하여 다른 도메인의 자원을 호출 할 수 있게 할 수 있다. Spring Security 는 필터 기반의 CORS 를 지원하고 있어 아래와 같이 손쉽게 처리가 가능하다. 

<http auto-config="true" use-expressions="true" disable-url-rewriting="true">

  <cors configuration-source-ref="corsSource"/> 

  <headers>

    <frame-options policy="SAMEORIGIN" />

  </headers>

  <intercept-url pattern="/*" access="permitAll"/>

<intercept-url pattern="/error/*" access="permitAll"/>

<intercept-url pattern="/data/**" access="permitAll"/>

<intercept-url pattern="/display/**" access="permitAll"/>

<intercept-url pattern="/accounts/**" access="permitAll"/>

<intercept-url pattern="/secure/studio/**" access="hasRole('ROLE_ADMINISTRATOR') or hasRole('ROLE_SYSTEM') or hasRole('ROLE_DEVELOPER')" />

<intercept-url pattern="/secure/data/**" access="hasRole('ROLE_USER') or hasRole('ROLE_SYSTEM')" /> 

  <!-- Form Login Page Setting -->

  <form-login login-page="/accounts/login"

username-parameter="username" 

password-parameter="password"

login-processing-url="/accounts/auth/login_check"

authentication-success-handler-ref="authenticationSuccessHandler"

authentication-failure-handler-ref="authenticationFailureHandler" />

  <http-basic />

  ...


</http>


<beans:bean id="corsSource" class="org.springframework.web.cors.UrlBasedCorsConfigurationSource">

  <beans:property name="corsConfigurations">

    <util:map>

      <beans:entry key="/**">

      <beans:bean class="org.springframework.web.cors.CorsConfiguration">

      <beans:property name="allowCredentials" value="true"/>

      <beans:property name="allowedHeaders">

      <beans:list>

        <beans:value>Authorization</beans:value>

        <beans:value>Content-Type</beans:value>

        <beans:value>responseType</beans:value>

        <beans:value>encoding</beans:value>

      </beans:list>

      </beans:property>

      <beans:property name="allowedMethods">

        <beans:list>

        <beans:value>POST</beans:value>

        <beans:value>GET</beans:value>

        <beans:value>PUT</beans:value>

        <beans:value>DELETE</beans:value>

        <beans:value>OPTIONS</beans:value>

        </beans:list>

      </beans:property>

      <beans:property name="allowedOrigins" value="*" />

      <beans:property name="exposedHeaders">

      <beans:list>

        <beans:value>Location</beans:value>

        <beans:value>Content-Disposition</beans:value>

      </beans:list>

      </beans:property>

      <beans:property name="maxAge" value="86400" /></beans:bean>

    </beans:entry>

    </util:map>

  </beans:property>

</beans:bean>


JWT 기반 인증 및 검증

아이디/비밀번호 기반의 인증에서 JWT 토튼을 사용하려면 인증이 성공하면 클라이언트에 토큰 값을 응답해주어야 한다. 아래는 스프링 컨트롤러에 간단하게 로그인 인증후 Jwt 토큰 값을 리턴하는 예이다.

LoginRequest.java

@RequestMapping(value = "/signin.json", method = { RequestMethod.POST})

public ResponseEntity<JwtResponse> authenticateUser(@RequestBody LoginRequest loginRequest) { 

Authentication authentication = authenticationManager.authenticate(

new UsernamePasswordAuthenticationToken(

loginRequest.getUsername(), 

loginRequest.getPassword()));

SecurityContextHolder.getContext().setAuthentication(authentication);

String jwt = jwtTokenProvider.createToken(authentication); 

CommuintyUserDetails details = SecurityHelper.getUserDetails(authentication);

return ResponseEntity.ok( 

            new JwtResponse(

                jwt

                details.getUser(), 

                getRoles(details.getAuthorities())));


웹 프로그램은 아래와 같이 인증후 획득한 토큰값을 저장하고 서버와 통신이 필요한 경우 헤더에 토큰 값을 포함하여 전달하게 된다. 서버 통신은 axois 를 사용하여 구현하였다. 참고로 코드 구현에 있어 UI 부분은 kendoui 를 사용하였다. ( kendoui 를 사용하려면 개발자라이선스가 필요함)

var renderTo = $('form[name=form-signin]');
var observable = new kendo.observable({
    username : null,
    password : null
});

var validator = renderTo.kendoValidator({
  errorTemplate: "

#=message#

" }).data("kendoValidator");; kendo.bind( renderTo , observable ); renderTo.submit(function(e) { e.preventDefault(); if( validator.validate() ){ kendo.ui.progress(renderTo, true); axios.post(studio.services.getApiUrl('/data/accounts/signin.json'), JSON.stringify(observable), { headers: { "Content-Type": "application/json" }} ).then(response => { const data = response.data; studio.services.accounts.loginSuccess(studio.services.accounts.state, data); window.location.replace("index.html"); }).catch(function (error) { // handle error studio.ui.handleAxiosError(error); observable.set('password', null); }) .then(function () { // always executed kendo.ui.progress(renderTo, false); }); } });

index.js
 
 const user = JSON.parse(localStorage.getItem("user"));
 const initialState = user
  ? { status: { loggedIn: true }, user }
  : { status: { loggedIn: false }, user: null };
  
 function authHeader() {
  // return authorization header with jwt token
  let _user = JSON.parse(localStorage.getItem("user"));
  if (_user && _user.jwtToken) {
    return { Authorization: "Bearer " + _user.jwtToken };
  } else {
    return {};
  }
  }

 function loginSuccess(state, data) {
  if (data.jwtToken) {
    // store user details and jwt token in local storage to keep user logged in between page refreshe
    localStorage.setItem("user", JSON.stringify(data));
  }
  state.loggedIn = true;
  state.user = data;
}

서버 호출이 필요한 경우 아래와 같은 방식으로 헤더에 jwt 포함하여 전송한다. 

sttings-locale.html 
const headers = {
  Accept: "application/json",
  "Content-Type": "application/json"
};
Object.assign(headers, studio.services.accounts.authHeader());

...

axios({
  url: studio.services.getApiUrl('/data/secure/mgmt/locale/save-or-update.json'),
  method: "post",
  data: JSON.stringify({ locale: $this.locale, timeZone: $this.timezone }),
  headers: headers
}).then(response => {
  let data = response.data;
  dialog.close();
}).catch(function (error) {
  studio.ui.handleAxiosError(error);
}).then(function () {
  // always executed
  kendo.ui.progress($('.k-dialog'), false);
});
                      

JWT 기반 인증 적용 후  경험한 문제들

  • spring security 에서 중복로그인 방지를 설정한 경우 중복 로그인 오류로 인하여 정상적인 응답을 받지 못하는 경우가 다수 발생했다. 
  • 인증된 사용자에게만 보여지는 이미지와 같은 바이너리 형식을 처리하는 경우에 어려움이 있었다. 

참고자료

Securing Spring Boot with JWTs
JWT (JSON Web Token) 이해와 활용
CORS support in Spring Framework