이전 글에 이어서 Resource Server를 Spring Security Oauth 없이 구현해보겠습니다.

전체 소스는 Github에 있습니다.

 

※ 개발 환경 : STS 4.8.1, JDK 1.8, Spring Boot 2.1.6, Mybatis 2.1, MySQL Community 5.6, Maven 4.0.0

 

1. Resource Server 구현을 위한 Spring Boot 프로젝트 생성 및 의존성 설정

프로젝트, 패키지 명은 적절하게 선택하여 Spring Stater Project 생성합니다.

의존성 설정은 아래의 pom.xml 파일을 참고해주세요.

 

2. Resource Server 프로젝트 구조

아래 이미지와 같은 구조를 가지게 됩니다.

- com.codjecd.common : Custom Exception 등 Custom 패키지, MessageProperties 등 공통 컴포넌트 패키지

- com.codejcd.config : 각종 설정 파일들의 패키지

- com.codejcd.dao : DB 액세스를 파일들을 위한 패키지

- com.codejcd.service : 서비스 레이어 패키지

- com.codejcd.controller : 컨트롤러 레이어 패키지

- com.codejcd.util : 유틸 파일 패키지

 

3. Message Source 설정

Exception 등 서버에서 처리할 메시지를 정의하고 맵핑하는 설정.
/src/main/resources/messages/ message.*.properties 파일들과 맵핑됩니다.

Locale을 KOREA로 설정했기떄문에 message.ko.properties 가 기본 메시지 프로퍼티로 사용됩니다.

 

3.1 Message Source Configuration

package com.codejcd.config;

import java.util.Locale;

import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ReloadableResourceBundleMessageSource;

/**
 * 메시지 설정
 */
@Configuration
public class MessageSourceConfiguration {

	@Bean
	public MessageSource messageSource() {
		ReloadableResourceBundleMessageSource source = new ReloadableResourceBundleMessageSource();

	    source.setBasename("classpath:/messages/message");
	    source.setDefaultEncoding("UTF-8");
	    source.setCacheSeconds(10);
	    source.setUseCodeAsDefaultMessage(true);
	    Locale.setDefault(Locale.KOREA);
	   
	    return source;
	}


}

 

3.2 Message Source Properties

src/main/resources/messages 폴더를 생성하고 message_ko.properties 파일을 생성합니다.

code와 message 값을 정의합니다.
프로젝트의 크기에 따라 code 의 범위는 적절하게 조절하세요.
여기서는 데모 앱이므로 범위가 작습니다.

error.code.common.success=000
error.message.common.success=Process Success

error.code.common.occured.exception=999
error.message.common.occured.exception=Process Fail

error.code.userId.invalid=000
error.message.userId.invalid=User ID is invalid

error.code.userPassword.invalid=001
error.message.userPassword.invalid=User Password is invalid

error.code.token.expiration.invalid=010
error.message.token.expiration.invalid=token is invalid

error.code.token.expiration=011
error.message.token.expiration=token is expired

error.code.token.issuer.invalid=012
error.message.token.issuer.invalid=issuer is invalid

error.code.token.audience.invalid=013
error.message.token.audience.invalid=audience is invalid

error.code.token.id.invalid=014
error.message.token.id.invalid=token ID is invalid

error.code.authorization.invalid=020
error.message.authorization.invalid=authorization is invalid

 

3.3 Message Properties

MessageSoruceAware를 상속받는 클래스.

3.2에 정의한 properties 파일에서 key, vaule 관계로 정의한 코드, 메시지 값을 가져와 

정의한 메서드 통해 사용할 수 있다.

 

예시) MessageProperties.prop("error.code.common.occured.exception") 

package com.codejcd.common;

import java.text.MessageFormat;
import java.util.Locale;
import org.springframework.context.MessageSource;
import org.springframework.context.MessageSourceAware;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;

/**
 * Message Component
 * 커스터마이징 메시지 
 */
@Component
public class MessageProperties implements MessageSourceAware{
	
    private static MessageSource messageSource;

    public void setMessageSource (MessageSource messageSource) {
        MessageProperties.messageSource = messageSource;
    }

    public static String prop(String key) {
        return messageSource.getMessage(key, null, Locale.KOREA);
    }

    public static String prop(String key,  HttpServletRequest request) {
        return MessageProperties.msg(key, request);
    }

    public static String prop(String key, Locale locale) {
        return MessageProperties.msg(key, locale);
    }

    public static String propFormat(String key, HttpServletRequest request, Object...objects) {
        return MessageFormat.format(MessageProperties.msg(key, request), objects);
    }

    public static String propFormat(String key, Locale locale, Object...objects) {
        return MessageFormat.format(MessageProperties.msg(key, locale),objects);
    }

     public static String msg(String key, HttpServletRequest request){
         return messageSource.getMessage(key, null, Locale.KOREA);
     }

     public static String msg(String key, Locale locale){
         return messageSource.getMessage(key, null, locale);
     }

}

 

4. application.properties

서버 포트를 위한 설정만 있습니다.

server.port=8096

 

5. jwt.properties 

JWT 설정 값들을 관리하는 파일입니다.

JWT Access Token 과 Refresh Token 복호화에 필요한 값들.

jwt.secretKey=1523adjk@#
jwt.expireDay=3
jwt.issuer=codejcd
jwt.audience=codejcd_client
jwt.subject=access_token

 

6. Custom Exception 

Custom Exception을 처리하기 위한 클래스입니다.

Exception 클래스를 상속 받고 error code, message, description을 처리하기 위한 멤버 변수를 가집니다.

 

package com.codejcd.common;

/**
 * Customize Exception 
 * 커스터마이징 예외 처리 
 */
public class CustomException extends Exception {
	private static final long serialVersionUID = -3642478745758696786L;
	private String errorCode;
	private String errorMessage;
	private String errorDescription;
	
	public CustomException() {
	
	}
	
	public CustomException(String errorCode, String errorMessage) {
		this.errorCode = errorCode;
		this.errorMessage = errorMessage;
	}
	
	public CustomException(String errorCode, String errorMessage, String errorDescription) {
		this.errorCode = errorCode;
		this.errorMessage = errorMessage;
		this.errorDescription = errorDescription;
	}
	
	public String getErrorCode() {
		return errorCode;
	}
	public void setErrorCode(String errorCode) {
		this.errorCode = errorCode;
	}
	public String getErrorMessage() {
		return errorMessage;
	}
	public void setErrorMessage(String errorMessage) {
		this.errorMessage = errorMessage;
	}
	public String getErrorDescription() {
		return errorDescription;
	}
	public void setErrorDescription(String errorDescription) {
		this.errorDescription = errorDescription;
	}
}

 

7. Custom Response 

응답을 위한 Response 객체를 구현.

응답 코드(responseCode), 응답 메시지(responseMessage), 응답 결과 객체(result)를 멤버 변수로 가진다.

package com.codejcd.common;

/**
 * Customize Response
 * 커스터마이징 응답 객체
 */
public class Response {

	private String responseCode;
	private String responseMessage;
	private Object result;
	public String getResponseCode() {
		return responseCode;
	}
	public void setResponseCode(String responseCode) {
		this.responseCode = responseCode;
	}
	public String getResponseMessage() {
		return responseMessage;
	}
	public void setResponseMessage(String responseMessage) {
		this.responseMessage = responseMessage;
	}
	public Object getResult() {
		return result;
	}
	public void setResult(Object result) {
		this.result = result;
	}
}

 

8. Date Utill 

날짜, 시간 계산을 편하게 하기 위한 유틸 클래스.

메서드 설명은 이전 글을 참고해주시기 바랍니다.

package com.codejcd.util;

import java.text.ParsePosition;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;

public class DateUtil {

	public static final String YYYYMMDDHHmm = "yyyyMMddHHmm";
	
    public static Date getDate(String dateString, String format) throws Exception{
    	SimpleDateFormat formatter = new SimpleDateFormat(format);
    	ParsePosition pos = new ParsePosition(0);
    	return formatter.parse(dateString, pos);
    }
	
	public static String getNow(String fromFormat) {
		Calendar cal = Calendar.getInstance();
		Date today = cal.getTime();
		SimpleDateFormat formatter = new SimpleDateFormat(fromFormat);
		return formatter.format(today);
	}
	
	public static Date getNowDate() {
		Calendar cal = Calendar.getInstance();
		return cal.getTime();
	}
	
    public static String addDate(String dateString, String format, int days){
    	Calendar cal = Calendar.getInstance();
    	SimpleDateFormat formatter = new SimpleDateFormat(format);
        ParsePosition pos = new ParsePosition(0);
        cal.setTime(formatter.parse(dateString, pos));
        cal.add(Calendar.DATE,days);

		return formatter.format(cal.getTime());
    }
    
    public static boolean isBefore(Date from, Date to) {
		if (from == null || to == null) {
			return false;
		}
		if (to.before(from)) {
			return false;
		}
		return true;
	}

}

 

9. Resource Controller

RestController 어노테이션을 통해 Bean 등록을 하고 JSON 포맷으로 데이터를 리턴합니다.

package com.codejcd.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.codejcd.common.CustomException;
import com.codejcd.common.MessageProperties;
import com.codejcd.common.Response;
import com.codejcd.service.TokenService;

/**
 * 리소스 컨트롤러
 */
@RestController
public class ResourceController {

	@Autowired
	private TokenService tokenService;
	
	/**
	 * 제한된 자원의 접근 API
	 * @param bearerToken
	 * @return
	 */
    @RequestMapping("/resource")
    public Response resource(@RequestHeader(value="Authorization") String bearerToken) {
    	Response response = new Response();
    	
    	try {
    		if (tokenService.checkAccessToken(bearerToken)) { // token 검증, token 검증은 각 API에 Aspect나 Intercepter를 활용하여 공통 처리도 가능. 
    			// 리소스 접근
            	response.setResponseCode(MessageProperties.prop("error.code.common.success"));
        		response.setResponseMessage(MessageProperties.prop("error.message.common.success"));
    		}
    	} catch (CustomException e) {
    		response.setResponseCode(MessageProperties.prop(e.getErrorCode()));
    		response.setResponseMessage(MessageProperties.prop(e.getErrorMessage()));
    	} catch (Exception e) {
            response.setResponseCode(MessageProperties.prop("error.code.common.occured.exception"));
            response.setResponseMessage(MessageProperties.prop("error.message.common.occured.exception"));
            e.printStackTrace();
    	} finally {
    		
    	}
    	return response;
    }
}

 

  • /resource
    RequestHeader 어노테이션을 사용하여 header로 전송하는 Authorization 값을 핸들링합니다.
    TokenService 의 메서드를 호출하여 토큰 값을 검증하고 검증이 정상 처리될 경우
    정상 응답 값을 세팅하여 리턴합니다.

10. Token Service, Token Service Impl

Token 서비스 레이어 입니다.

package com.codejcd.service;

public interface TokenService {
	
	/**
	 * token 검증
	 * @param accessToken
	 * @return
	 * @throws Exception
	 */
	public boolean checkAccessToken(String accessToken) throws Exception;
}
package com.codejcd.service;

import java.util.Date;
import javax.xml.bind.DatatypeConverter;
import org.apache.tomcat.util.codec.binary.Base64;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.PropertySource;
import org.springframework.stereotype.Service;
import com.codejcd.common.CustomException;
import com.codejcd.common.MessageProperties;
import com.codejcd.util.DateUtil;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;

@Service
@PropertySource("classpath:jwt.properties")
public class TokenServiceImpl implements TokenService {
	
	@Value("${jwt.secretKey}")
	private String SECRET_KEY;
	
	@Value("${jwt.issuer}")
	private String ISSUER;
	
	@Value("${jwt.audience}")
	private String AUDIENCE;
	
	@Value("${jwt.subject}")
	private String SUBJECT;
	
	@Override
	public boolean checkAccessToken(String accessToken) throws Exception {
		if (null == accessToken || "".equals(accessToken)) {
			throw new CustomException(MessageProperties.prop("error.code.token.invalid")
					, MessageProperties.prop("error.message.token.invalid"));
		}
		
		String[] bearerToken = accessToken.split("Bearer "); 
		if (null == bearerToken[1]) {
			throw new CustomException(MessageProperties.prop("error.code.token.not.bearer")
					, MessageProperties.prop("error.message.token.not.bearer"));
		}
		
		boolean result = false;
		byte[] secretKeyBytes = DatatypeConverter.parseBase64Binary(Base64.encodeBase64String(SECRET_KEY.getBytes()));
		Claims claims = null;
		claims = Jwts.parser()
					.setSigningKey(secretKeyBytes)
					.parseClaimsJws(bearerToken[1]).getBody();
		
		Date expitration;
		Date now = DateUtil.getNowDate();
		String issuer;
		String audience;
		
		
		if (null == claims.getExpiration()) {
			throw new CustomException(MessageProperties.prop("error.code.token.expiration.invalid")
					, MessageProperties.prop("error.message.token.expiration.invalid"));	
		}
		expitration = claims.getExpiration();
		 
		if (DateUtil.isBefore(expitration, now)) {
			throw new CustomException(MessageProperties.prop("error.code.token.expiration")
					, MessageProperties.prop("error.message.token.expiration"));
		}
		
		if (null == claims.getIssuer() ||  "".equals(claims.getIssuer())) {
			throw new CustomException(MessageProperties.prop("error.code.token.issuer.invalid")
					, MessageProperties.prop("error.message.token.issuer.invalid"));		
		}
		issuer = claims.getIssuer();
		
		if (!ISSUER.equals(issuer)) {
			throw new CustomException(MessageProperties.prop("error.code.token.issuer.invalid")
					, MessageProperties.prop("error.message.token.issuer.invalid"));
		}
		
		if (null == claims.getAudience() ||  "".equals(claims.getAudience())) {
			throw new CustomException(MessageProperties.prop("error.code.token.audience.invalid")
					, MessageProperties.prop("error.message.token.audience.invalid"));		
		}
		audience = claims.getAudience();
		
		if (!AUDIENCE.equals(audience)) {
			throw new CustomException(MessageProperties.prop("error.code.token.audience.invalid")
					, MessageProperties.prop("error.message.token.audience.invalid"));
		}
		
		if (null == claims.getId() ||  "".equals(claims.getId())) {
			throw new CustomException(MessageProperties.prop("error.code.token.id.invalid")
					, MessageProperties.prop("error.message.token.id.invalid"));		
		}
		result = true;
		//claims.getIssuedAt();
		//claims.getSubject();
		
		return result;
	}
	
}

 

  • checkAccessToken 메서드
    Bearere Token에 대한 null, type 등 기본적인 유효성 체크 이후에
    jwt.properties에 미리 정의해둔 secret key를 통해 디코딩하여 Claims 객체를 만듭니다.
    Claims에 정의된 메서드를 통해 속성에 접근하여 토큰의 유효성을 체크하고 결과를 리턴합니다.

11. 테스트

테스트 툴로는 POSTMAN을 사용했습니다.
이전 글에 미리 만든 Authorization 서버를 스타트하고 Resource 서버도 스타트합니다.

 

http://localhost:8095/oauth2/tokens API를 저번 글에서

미리 생성해둔 Client 정보와 User 정보를 세팅하고 호출하여 토큰 값을 얻습니다.

http://localhost:8096/resource API의 header에 발급받은 Bearer Token Type에 Access Token을 세팅하고 

호출하면 정상적으로 접근하는 것을 확인할 수 있다.

 

12. 마치며

1부 - Oauth 2.0과 JWT 개념 

2부~3부 - Spring Security Oauth Passsword Credential Demo 구현

4~5부 - Spring Boot Oauth Passsword Credential Demo 구현

 

1~5부에 걸쳐서 Oauth 개념과 인증과 리소스 서버 데모를 간단하게 구현해봤습니다.

기본 흐름이 어떻게 흘러가는지는 이해하셨을 거라 생각됩니다.

1부에서 개념을 소개하면서 직접 실무에서 사용을 했다고 했는데요.

사용하면서 느낀 점을 한번 적어보겠습니다.

데모 앱에서는 HTTP 프로토콜을 사용하고 따라서

토큰의 경우 클라이언트가 접근 가능한 환경이면 비교적 쉽게 탈취 가능합니다.

base64 encoding 외에 별다른 보안 장치가 없기 때문에

토큰에 중요한 값을 실어 보낸다면 탈취 시 문제가 될 수도 있습니다.

따라서 실제 상용 환경이라면 통신 구간 암호화를 위해 HTTPS 프로토콜을 당연히 사용해야 합니다.

Access Token은 만료 주기를 짧게 해서 탈취에 대한 대비를 어느 정도 할 수도 있습니다.

다만 주기가 짧아지는 만큼 빈번하게 토큰을 발급해야 합니다.

Refresh Token을 탈취당할 것을 대비하여 DB에 저장하고 어느 정도 제어를 할 수 있는

방법은 있지만 Refresh Token을 계속 저장하고 관리해야 하는 부담이 있습니다.

서비스가 커질수록 부담은 늘어납니다.

보안이 엄격하게 유지되어야 하는 서비스에서는 클라이언트에 저장되는 부분이라던지

토큰 탈취에 대한 신경을 많이 쏟아야 하는 구조입니다.

물론 Cookie나 Session을 사용한다고 해서 탈취 위험이 전혀 없는 것은 아닙니다.

비즈니스 상황에 가장 적합하게 분석하여 적용해야 한다고 봅니다.

 

 

 

 

 

 

 

 

 

+ Recent posts