이번에는 Spring Security Oauth을 사용하지 않고 password credential 방식을 직접 구현해보겠습니다.

DB는 user 테이블은 스키마는 그대로 사용하겠습니다.

삭제하고 다시 만들어도 상관없습니다.

그 외에 oauth_client, oauth_refresh_token 라는 테이블을 각각 생성하고
client, refresh token 데이터를 다루는 용도 사용하겠습니다.

각 테이블 생성 SQL은 Gihub 소스의 src/main/resources/db/scheme.sql 파일을 참고해주세요.

 

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

    

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

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

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

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>2.1.6.RELEASE</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>
	<groupId>com.codejcd</groupId>
	<artifactId>SpringbootMybatisMysqlOauth2.0-AuthServer2</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>SpringbootMybatisMysqlOauth2.0-AuthServer2</name>
	<description>SpringbootMybatisMysqlOauth2.0-AuthServer2</description>

	<properties>
		<java.version>1.8</java.version>
	</properties>

	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
		<dependency>
			<groupId>org.mybatis.spring.boot</groupId>
			<artifactId>mybatis-spring-boot-starter</artifactId>
			<version>2.1.0</version>
		</dependency>
		<!-- https://mvnrepository.com/artifact/org.springframework.security/spring-security-crypto -->
		<dependency>
    		<groupId>org.springframework.security</groupId>
    		<artifactId>spring-security-crypto</artifactId>
    		<version>5.1.5.RELEASE</version><!--$NO-MVN-MAN-VER$-->
		</dependency>
		<dependency>
			<groupId>mysql</groupId>
			<artifactId>mysql-connector-java</artifactId>
			<scope>runtime</scope>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
		<dependency>
    		<groupId>io.jsonwebtoken</groupId>
    		<artifactId>jjwt</artifactId>
    		<version>0.9.1</version>
		</dependency>
		<dependency>
			<groupId>javax.xml.bind</groupId>
			<artifactId>jaxb-api</artifactId>
		</dependency>
	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
			</plugin>
		</plugins>
	</build>

</project>
  • spring-security-crypto
    PasswordEncoder 구현에 사용됩니다.

  • jjwt
    JWT 구현에 사용됩니다.

2. Authorization Server 프로젝트 구조

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

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

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

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

- com.codejcd.entity : 엔티티를 파일들을 위한 패키지

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

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

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

 

 

3. Mybatis 설정

MybatisConfiguration 클래스를 생성하고 Properties에서 읽어올 설정 값과

SQL Session과 Mapper 설정을 합니다.

package com.codejcd.config;

import javax.sql.DataSource;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.SqlSessionTemplate;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import com.zaxxer.hikari.HikariDataSource;

@Configuration
@MapperScan(basePackages = "com.codejcd.*.dao.**", sqlSessionFactoryRef = "sqlSessionFactory")
public class MybatisConfiguration {

	@Bean(name = "dataSource")
	@ConfigurationProperties(prefix = "spring.datasource.hikari")
	public DataSource dataSource() {
		return new HikariDataSource();
	}
	
	@Bean(name = "sqlSessionFactory")
	public SqlSessionFactory sqlSessionFactory (
			@Qualifier("dataSource") DataSource dataSource,
			ApplicationContext applicationContext) throws Exception {
		
			SqlSessionFactoryBean sqlSessionFactory = new SqlSessionFactoryBean();
			sqlSessionFactory.setDataSource(dataSource);
			sqlSessionFactory.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("mybatis/mapper/*.xml"));
			
		return sqlSessionFactory.getObject(); 
	}
	
	@Bean(name = "sqlSession")
	public SqlSessionTemplate sqlSession(SqlSessionFactory sqlSessionFactory) throws Exception {
		return new SqlSessionTemplate(sqlSessionFactory);
	}
	
}


4. Message Source 설정

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

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

 

4.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); // 기본 Locale
	   
	    return source;
	}
}

 

4.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.invalid=010
error.message.token.invalid=token is invalid

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

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

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

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

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

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

error.code.client.secretkey.invalid=030
error.message.client.secretkey.invalid=client secret key is invalid

 

5. application.properties

MySQL DB에 접근하기 위한 설정을 추가합니다.

테스트 설정이니 참고만 해주시고 절대로 상용에서 사용하면 안 되는 비밀번호입니다.

server.port=8095
spring.datasource.hikari.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.hikari.jdbc-url=jdbc:mysql://localhost:3306/oauth2?characterEncoding=UTF-8&serverTimezone=Asia/Seoul
spring.datasource.hikari.username=root
spring.datasource.hikari.password=1234
spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.connection-test-query=SELECT 1 FROM DUAL
spring.datasource.hikari.minimum-idle=5
spring.datasource.hikari.connection-timeout=100000

 

6. jwt.properties 

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

JWT Access Token 과 Refresh Token 생성에 필요한 값들.

 

7. Custom Exception 

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

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

package com.codejcd.common;

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;
	}
}

 

 

8. Custom Password Encoder 

Bcrypt 인코딩 방식을 사용하는  Password Encoder 구현.
Commponet 어노테이션을 사용하여 빈으로 등록한다.

package com.codejcd.common;

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;

/**
 * BCrypt Passwodencoder Component
 * BCrypt 의존성이 주입된 passwordencoder
 */
@Component
public class CustomPasswordEncoder implements PasswordEncoder {
	private PasswordEncoder passwordEncoder;
	
	public CustomPasswordEncoder() {
		this.passwordEncoder = new BCryptPasswordEncoder();
	}
	
	@Override
	public String encode(CharSequence rawPassword) {
		return passwordEncoder.encode(rawPassword);
	}
	
	@Override
	public boolean matches(CharSequence rawPassword, String encodedPassword) {
		return passwordEncoder.matches(rawPassword, encodedPassword);
	}
	

}

 

9. Custom Response 

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

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

package com.codejcd.common;

/**
 * 커스터마이징 응답 객체
 */
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;
	}
}

 

10. Date Utill 

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

package com.codejcd.util;

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

/**
 * Date Util
 *
 */
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 addMinute(String dateString, String format, int minute){
    	Calendar cal = Calendar.getInstance();
    	SimpleDateFormat formatter = new SimpleDateFormat(format);
        ParsePosition pos = new ParsePosition(0);
        cal.setTime(formatter.parse(dateString, pos));
        cal.add(Calendar.MINUTE,minute);

		return formatter.format(cal.getTime());
    }
	
    public static String addHour(String dateString, String format, int hours){
    	Calendar cal = Calendar.getInstance();
    	SimpleDateFormat formatter = new SimpleDateFormat(format);
        ParsePosition pos = new ParsePosition(0);
        cal.setTime(formatter.parse(dateString, pos));
        cal.add(Calendar.HOUR,hours);

		return formatter.format(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;
	}

}

 

  • getDate 메서드
    String 날짜 값, 포맷 값을 매개변수로 받아서
    날짜를 Date로 반환.

  • getNow 메서드
    String 포맷 값을 매개변수로 받아서
    오늘 날짜를 String로 반환.

  • getNowDate 메서드
    매개변수 없이 오늘 날짜를 Date로 반환.

  • addMinute 메서드
    String 날짜 값, 포맷, 추가할 시간(Minute)을 매개변수로 받아서
    연산하고 날짜 값을 String으로 반환. 

  • addHour 메서드
    String 날짜 값, 포맷, 추가할 시간(Hour)을 매개변수로 받아서
    연산하고 날짜 값을 String으로 반환.

  • addDate 메서드
    String 날짜 값, 포맷, 추가할 시간(days)을 매개변수로 받아서
    연산하고 날짜 값을 String으로 반환.

  • isBefore 메서드
    Date 비교할 두 날짜 값을 매개변수로 받아서
    비교하고 이전 날짜 여부를 판단한 값을 boolean 으로 반환.

11. Client, Token, RefreshToken, User Entity 구현

Client, Token, RefreshToken, User 객체를 생성합니다.

RefreshToken은 Token을 상속받습니다.

멤버변수와 getter/setter에 대한 내용이므로 소스를 확인해주시고

설명은 생략하겠습니다.

package com.codejcd.entity;

public class Client {
	private int clientSeq;
	private String clientId;
	private String clientSecret;
	private String status;
	private String regDate;
	
	public String getClientId() {
		return clientId;
	}
	public void setClientId(String clientId) {
		this.clientId = clientId;
	}
	public String getClientSecret() {
		return clientSecret;
	}
	public void setClientSecret(String clientSecret) {
		this.clientSecret = clientSecret;
	}
	public int getClientSeq() {
		return clientSeq;
	}
	public void setClientSeq(int clientSeq) {
		this.clientSeq = clientSeq;
	}
	public String getStatus() {
		return status;
	}
	public void setStatus(String status) {
		this.status = status;
	}
	public String getRegDate() {
		return regDate;
	}
	public void setRegDate(String regDate) {
		this.regDate = regDate;
	}
	
	
}
package com.codejcd.entity;

public class Token {
	private String token;
	public String getToken() {
		return token;
	}
	public void setToken(String token) {
		this.token = token;
	}
}
package com.codejcd.entity;

public class RefreshToken extends Token {

	private int tokenId;
	private int refreshTokenSeq;
	private String authentication;
	
	public int getRefreshTokenSeq() {
		return refreshTokenSeq;
	}
	public void setRefreshTokenSeq(int refreshTokenSeq) {
		this.refreshTokenSeq = refreshTokenSeq;
	}
	public String getAuthentication() {
		return authentication;
	}
	public void setAuthentication(String authentication) {
		this.authentication = authentication;
	}
	public int getTokenId() {
		return tokenId;
	}
	public void setTokenId(int tokenId) {
		this.tokenId = tokenId;
	}


}
package com.codejcd.entity;

public class User  {
	private int userSeq;
	private String userId;
	private String password;
	private String name;
	private String status;

	public int getUserSeq() {
		return userSeq;
	}

	public void setUserSeq(int userSeq) {
		this.userSeq = userSeq;
	}

	public String getUserId() {
		return userId;
	}

	public void setUserId(String userId) {
		this.userId = userId;
	}

	public String getPassword() {
		return password;
	}

	public void setPassword(String password) {
		this.password = password;
	}

	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}

	public String getStatus() {
		return status;
	}

	public void setStatus(String status) {
		this.status = status;
	}

}

 

12. Client, Refresh Token , User Mapper 구현

12.1 Client Mapper

oauth_client 테이블에 접근하여 SQL 을 사용하여 조회하는 Mapper입니다.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.codejcd.mapper.ClientMapper">
	<sql id="table_name">
		oauth_client
	</sql>

	<select id="selectClientByClientId" parameterType="com.codejcd.entity.Client" resultType="com.codejcd.entity.Client" >
		SELECT client_seq as clientSeq
			, client_id as clientId
			, client_secret as clientSecret
  			, status as status
  			, reg_date as regDate
  		 FROM <include refid="table_name" />
  		WHERE client_id = #{clientId}  	 	 
	</select>
	
	<insert id="registClient" parameterType="com.codejcd.entity.Client" keyProperty="clientSeq" useGeneratedKeys="true" >
		INSERT INTO <include refid="table_name" />
		(
			client_seq
			, client_id 
			, client_secret 
  			, status 
  			, reg_date 
		) VALUES
		(
			#{clientSeq}
			, #{clientId}
			, #{clientSecret}
			, 'A'
			, now()
		)
	</insert>
</mapper>

 

  • selectClientByClientId
    oauth_client 테이블을 client_id 칼럼을 WHERE 조건문으로 조회하는 SQL입니다.

  • registClient
    oauth_client 테이블에 cleint 정보를 등록하는 SQL입니다.

12.2 Refresh Token Mapper

oauth_refresh_token 테이블에 접근하여 SQL을 사용하여 조회하는 Mapper입니다.

이전 글에서 구현 시에는 refresh_token을 저장하지는 않았는데요.

보통 Access Token은 만료 주기를 짧게 설정하고 Refresh Token 만료 주기를 길게 설정합니다.

이럴 경우 Refresh Token이 탈취당할 경우 계속 Access Token을 발급받을 수 있기 때문에

 Refresh Token을 비교하여 수정/삭제 같은 조치를 하여 접근 제어를 하는 방안이 있습니다.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.codejcd.mapper.RefreshTokenMapper">
	<select id="countRefreshTokenByToken" parameterType="com.codejcd.entity.RefreshToken" resultType="int" >
		SELECT COUNT(token_id) 
  		  FROM oauth_refresh_token
  		 WHERE token = #{token}  	 
	</select>
	<select id="selectRefreshTokenByAuthentication" parameterType="com.codejcd.entity.RefreshToken"  resultType="com.codejcd.entity.RefreshToken">
		SELECT token_id AS tokenId
 				, token AS token
				, authentication AS authentication
  		 FROM oauth_refresh_token
  		WHERE authentication = #{authentication} 
  	</select> 
	<insert id="insertRefreshToken" parameterType="com.codejcd.entity.RefreshToken" keyProperty="tokenId" useGeneratedKeys="true" >
		INSERT INTO oauth_refresh_token
		(
			token_id
 			, token
			, authentication
		) VALUES
		(
			#{tokenId}
			, #{token}
			, #{authentication}
		)
	</insert>
	<delete id="deleteRefreshTokenByAuthentication" parameterType="com.codejcd.entity.RefreshToken">
		DELETE 
		  FROM oauth_refresh_token
		 WHERE authentication = #{authentication}
	</delete>
	<update id="updateRefreshTokenByAuthentication" parameterType="com.codejcd.entity.RefreshToken" >
		UPDATE oauth_refresh_token SET
 			   token = #{token}
 		 WHERE authentication = #{authentication}
	</update>
</mapper>

 

  • countRefreshTokenByToken
    oauth_refresh_token 테이블에 token 칼럼을
    WHERE 조건문으로 조회하여 결과 값을 카운트하는 SQL입니다.

  • selectRefreshTokenByAuthentication
    oauth_refresh_token 테이블에 token 칼럼을
    WHERE 조건문으로 조회하여 결과 값을 얻는 SQL입니다.

  • insertRefreshToken
    oauth_refresh_token 테이블에 Refresh Token 정보를 등록하는 SQL입니다.

  • deleteRefreshTokenByAuthentication
    oauth_refresh_token 테이블에 authentication 칼럼을
    WHERE 조건문으로 삭제하는 SQL입니다.

  • updateRefreshTokenByAuthentication
    oauth_refresh_token 테이블에 authentication 칼럼을
    WHERE 조건문으로 하여, token 칼럼 값을 업데이트하는 SQL입니다.

12.3 User Mapper

user 테이블에 접근하여 SQL을 사용하여 조회하는 Mapper입니다.

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 
<mapper namespace="com.codejcd.mapper.UserMapper">
     <select id="selectUserByUserId" parameterType="String" resultType="com.codejcd.entity.User" >
  		SELECT user_seq as userSeq
  				, user_id as userId
  				, password as password
  				, name as name
  				, status as status
  				, reg_date as regDate
  		  FROM user
  		WHERE user_id = #{userId}  	 
     </select>
     <select id="selectUser" parameterType="com.codejcd.entity.User" resultType="com.codejcd.entity.User">
       	SELECT user_seq as userSeq
  				, user_id as userId
  				, password as password
  				, name as name
  				, status as status
  				, reg_date as regDate
  		  FROM user
  		 WHERE user_id = #{userId}  
  		   AND password = #{password}	
     </select>
     <insert id="registUser" parameterType="com.codejcd.entity.User" keyProperty="userSeq" useGeneratedKeys="true" >
		INSERT INTO user
		(
			user_seq
			, user_id 
			, name
			, password 
  			, status 
  			, reg_date 
		) VALUES
		(
			#{userSeq}
			, #{userId}
			, #{name}
			, #{password}
			, 'A'
			, now()
		)
	</insert>
</mapper>

 

 

  • selectUserByUserId
    user 테이블을 user_id 칼럼을 WHERE 조건문으로 조회하여
    결과 값을 얻는 SQL입니다.

  • selectUser
    user 테이블을 user_id 칼럼과 password 칼럼을
    WHERE 조건문으로 조회하여 결과 값을 얻는 SQL입니다.

  • registUser
    user 테이블에 user 정보를 등록하는 SQL입니다.

13. Client, Refresh Token , User DAO 구현

Client, Refresh Token, User SQL Mapper에 접근하여 결과 값을 얻을 DAO를 작성합니다.

Repository 어노테이션을 통해 Bean으로 등록합니다.

package com.codejcd.dao;

import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Repository;

import com.codejcd.entity.Client;

@Repository
public class ClientDao {
	private final static String NAMESPACE = "com.codejcd.mapper.ClientMapper.";
	
	@Autowired
	@Qualifier("sqlSession")
	private SqlSessionTemplate sqlSession;
	
	public Client selectClientByClientId(String clientId) {
		Client client = new Client();
		client.setClientId(clientId);
		
		return sqlSession.selectOne(NAMESPACE + "selectClientByClientId", client); 
	}
	
	public int registClient(String clientId, String clientSecret) {
		Client client = new Client();
		client.setClientId(clientId);
		client.setClientSecret(clientSecret);
		
		return sqlSession.insert(NAMESPACE + "registClient", client); 
	}
}
package com.codejcd.dao;

import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Repository;

import com.codejcd.entity.RefreshToken;

@Repository
public class RefreshTokenDao {
	private final static String NAMESPACE = "com.codejcd.mapper.RefreshTokenMapper.";
	
	@Autowired
	@Qualifier("sqlSession")
	private SqlSessionTemplate sqlSession;
	
	public int countRefreshToken(RefreshToken refreshToken) {
		return sqlSession.selectOne(NAMESPACE+ "countRefreshTokenByToken", refreshToken);
	}
	
	public RefreshToken selectRefreshToken(RefreshToken refreshToken) {
		return sqlSession.selectOne(NAMESPACE+ "selectRefreshTokenByAuthentication", refreshToken);
	}
	
	public int insertRefreshToken(RefreshToken refreshToken) {
		return sqlSession.insert(NAMESPACE + "insertRefreshToken", refreshToken);
	}
	
	public int deleteRefreshToken(RefreshToken refreshToken) {
		return sqlSession.delete(NAMESPACE+ "deleteRefreshTokenByAuthentication", refreshToken);
	}
	
	public int updateRefreshToken(RefreshToken refreshToken) {
		return sqlSession.delete(NAMESPACE+ "updateRefreshTokenByAuthentication", refreshToken);
	}
}
package com.codejcd.dao;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Repository;
import com.codejcd.entity.User;

@Repository
public class UserDao {
	private final static String NAMESPACE = "com.codejcd.mapper.UserMapper.";
	
	@Autowired
	@Qualifier("sqlSession")
	private SqlSessionTemplate sqlSession;
	
	   public User selectUserByUserId(String userId) {
	    	return sqlSession.selectOne(NAMESPACE + "selectUserByUserId", userId);
	   }
	   
	   public User selectUser(String userId, String password) {
		   User user = new User();
		   user.setUserId(userId);
		   user.setPassword(password);
		   
		   return sqlSession.selectOne(NAMESPACE + "selectUser", user);
	   }
	   
		public int registUser(String userId, String userName, String password) {
			User user = new User();
			
			user.setUserId(userId);
			user.setName(userName);
			user.setPassword(password);
			
			return sqlSession.insert(NAMESPACE + "registUser", user); 
		}

}

 

14. Client Service, Token Service, User Service Impl

Serivce 인터페이스와 구현체 클래스로 구성됩니다.

Service 어노테이션으로 Bean 등록을 합니다.

현재는 인터페이스 구현체 관계가 1:1이기 때문에 Service Interface 경우 생략 가능합니다.

OCP 관점보다는 구현을 위한 최초 설계도 역할을 했다고 보면 될 것 같습니다.

1:1 관계에서 인터페이스 구현체 관계는 관습적인 불필요한 관계로 보고 있으니 프로젝트마다

코딩 컨벤션을 잘 정해서 따르면 될 것 같습니다.

 

14.1 Cllint Service, impl

package com.codejcd.service;

public interface ClientService {
	/**
	 * 클라이언트 조회 및 검증
	 * @param authorization
	 * @return
	 * @throws Exception
	 */
	public void checkClientByClientId(String authorization) throws Exception;
	
	/**
	 * 클라이언트 등록
	 * @param clientId
	 * @param clientSecret
	 * @return
	 * @throws Exception
	 */
	public int registClient(String clientId, String clientSecret) throws Exception; 
}
package com.codejcd.service;

import java.nio.charset.StandardCharsets;
import java.util.Base64;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.codejcd.common.CustomException;
import com.codejcd.common.CustomPasswordEncoder;
import com.codejcd.common.MessageProperties;
import com.codejcd.dao.ClientDao;
import com.codejcd.entity.Client;

@Service
public class ClientServiceImpl implements ClientService {

	@Autowired
	private ClientDao clientDao;
	
	@Autowired
	private CustomPasswordEncoder passwordEncoder;
	
	@Override
	public void checkClientByClientId(String authorization) throws Exception {
		
   		String[] client = null;
		if (authorization == null || !authorization.toLowerCase().startsWith("basic")) {
			throw new CustomException(MessageProperties.prop("error.code.authorization.invalid")
					, MessageProperties.prop("error.message.authorization.invalid"));	
		}
		
		// Authorization: Basic base64credentials
	    String base64Credentials = authorization.substring("Basic".length()).trim();
	    byte[] credDecoded = Base64.getDecoder().decode(base64Credentials);
	    String credentials = new String(credDecoded, StandardCharsets.UTF_8);
	    // credentials = username:password
	    client = credentials.split(":", 2);
	    Client resultClient = clientDao.selectClientByClientId(client[0]);
	    if (null == resultClient) {
			throw new CustomException(MessageProperties.prop("error.code.authorization.invalid")
					, MessageProperties.prop("error.message.authorization.invalid"));	
	    }

	    boolean result = passwordEncoder.matches(client[1], resultClient.getClientSecret());
	    
		if (!result) {
			throw new CustomException(MessageProperties.prop("error.code.client.secretkey.invalid")
					, MessageProperties.prop("error.message.client.secretkey.invalid"));	
		}
	}
	
	@Override
	public int registClient(String clientId, String clientSecret) throws Exception {
		clientSecret = passwordEncoder.encode(clientSecret);
		return clientDao.registClient(clientId, clientSecret);
	}
}

 

  • selectClientByClientId 메서드
    클라이언트 정보를 조회하여 유효한지 검증하고
    유효하지 않은 경우 Exception을 throw 합니다.
    클라이언트 정보는 컨트롤러를 통해 전달되는데
  • API 호출 시 HTTP Header 값에 authorization Basic 세팅하는 값이고
    Base64 Encoding 되어 있기 때문에 이를 Decoding 하여
    저장된 클라이언트 값과 비교하여 유효성을 검증합니다.

  • registClient 메서드
    클라이언트 정보를 매개변수로 받아서
    클라이언트 정보를 저장하는 DAO를 호출합니다.
    client secrert 값은 앞에서 미리 생성한
    password encoder를 사용하여 encoding 합니다.

14.2  Token Service, Impl

package com.codejcd.service;

import java.util.HashMap;

import com.codejcd.common.CustomException;
import com.codejcd.entity.User;

public interface TokenService {
	
	/**
	 * 액세스 및 리프레시 토큰 발행
	 * @param user
	 * @return
	 * @throws Exception
	 */
	public HashMap<String, Object> getTokens(User user) throws Exception;
	
	/**
	 * 액세스 토큰 생성
	 * @param user
	 * @return
	 * @throws Exception
	 */
	public String getAccessToken(User user) throws Exception;
	
	/**
	 * 액세스 토큰 검사
	 * @param accessToken
	 * @return
	 * @throws Exception
	 */
	public boolean checkAccessToken(String accessToken) throws Exception;
	
	/**
	 * 액세스 토큰 재발행
	 * @param accessToken
	 * @return
	 * @throws Exception
	 */
	public String refreshAccessToken(String accessToken) throws Exception;
	
	/**
	 * 리프레시 토큰 생성
	 * @param user
	 * @return
	 * @throws Exception
	 */
	public String getRefreshToken(User user) throws Exception;
	
	/**
	 * 리프레시 토큰 검사
	 * @param accessToken
	 * @return
	 * @throws Exception
	 */
	public User checkRefreshToken(String refreshToken) throws CustomException, Exception;
	
}
package com.codejcd.service;

import java.security.Key;
import java.util.Date;
import java.util.HashMap;

import javax.crypto.spec.SecretKeySpec;
import javax.xml.bind.DatatypeConverter;

import org.apache.tomcat.util.codec.binary.Base64;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.PropertySource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.codejcd.common.CustomException;
import com.codejcd.common.MessageProperties;
import com.codejcd.dao.RefreshTokenDao;
import com.codejcd.entity.RefreshToken;
import com.codejcd.entity.User;
import com.codejcd.util.DateUtil;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

@Service
@PropertySource("classpath:jwt.properties")
public class TokenServiceImpl implements TokenService {
	
	@Value("${jwt.accessToken.secretKey}")
	private String ACCESS_TOKEN_SECRET_KEY;
	
	@Value("${jwt.accessToken.expireHour}")
	private String ACCESS_TOKEN_EXPIRE_DAY;
	
	@Value("${jwt.accessToken.issuer}")
	private String ACCESS_TOKEN_ISSUER;
	
	@Value("${jwt.accessToken.audience}")
	private String ACCESS_TOKEN_AUDIENCE;
	
	@Value("${jwt.accessToken.subject}")
	private String ACCESS_TOKEN_SUBJECT;
	
	@Value("${jwt.refreshToken.secretKey}")
	private String REFRESH_TOKEN_SECRET_KEY;
	
	@Value("${jwt.refreshToken.expireDay}")
	private String REFRESH_TOKEN_EXPIRE_DAY;
	
	@Value("${jwt.refreshToken.issuer}")
	private String REFRESH_TOKEN_ISSUER;
	
	@Value("${jwt.refreshToken.audience}")
	private String REFRESH_TOKEN_AUDIENCE;
	
	@Value("${jwt.refreshToken.subject}")
	private String REFRESH_TOKEN_SUBJECT;
	
	@Autowired
	private UserService userService;
	
	@Autowired
	private RefreshTokenDao refreshTokenDao;
	
	@Override
	@Transactional(rollbackFor = {Exception.class})
	public HashMap<String, Object> getTokens(User user) throws Exception {
		String accessToken = getAccessToken(user);
		String refreshToken = getRefreshToken(user);
		HashMap<String, Object> resultMap = new HashMap<String, Object>();
		resultMap.put("accessToken", accessToken);
		resultMap.put("refreshToken", refreshToken);
		return resultMap;
	}
	
	@Override
	public String getAccessToken(User user) throws Exception {
		if (null == user) {
			throw new CustomException(MessageProperties.prop("error.code.userId.invalid")
					, MessageProperties.prop("error.message.userId.invalid"));
		}
		
        SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
		byte[] secretKeyBytes = DatatypeConverter.parseBase64Binary(Base64.encodeBase64String(ACCESS_TOKEN_SECRET_KEY.getBytes()));
		Key signKey = new SecretKeySpec(secretKeyBytes, signatureAlgorithm.getJcaName());
		
		//String strExpiration = DateUtil.addHour(DateUtil.getNow(DateUtil.YYYYMMDDHHmm), DateUtil.YYYYMMDDHHmm, Integer.parseInt(ACCESS_TOKEN_EXPIRE_DAY));
		String strExpiration = DateUtil.addMinute(DateUtil.getNow(DateUtil.YYYYMMDDHHmm), DateUtil.YYYYMMDDHHmm, 1);
		
		Date expiration = DateUtil.getDate(strExpiration, DateUtil.YYYYMMDDHHmm);
		//System.out.println("strExp : " + expiration);
		String userId = user.getUserId();
		
		if (null == userId && "".equals(userId)) {
			throw new CustomException(MessageProperties.prop("error.code.userId.invalid")
					, MessageProperties.prop("error.message.userId.invalid"));	
		}
		
	    JwtBuilder jwtBuilder = Jwts.builder()
	                .setId(userId) //jti(고유식별자)
	                .setAudience(ACCESS_TOKEN_AUDIENCE) // 토큰 대상자
	                .setIssuedAt(DateUtil.getNowDate()) //iat 발행날짜
	                .setSubject(ACCESS_TOKEN_SUBJECT) //sub 제목 
	                .setIssuer(ACCESS_TOKEN_ISSUER) // iss 발급자
	                .setExpiration(expiration) // exp 만료날짜
	                .signWith(signatureAlgorithm, signKey); // sign algo, key
		String temp =jwtBuilder.compact();
	    
	    checkAccessToken(temp);
	    
		return temp;
	}

	@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"));
		}
		
		boolean result = false;
		byte[] secretKeyBytes = DatatypeConverter.parseBase64Binary(Base64.encodeBase64String(ACCESS_TOKEN_SECRET_KEY.getBytes()));
		Claims claims = null;
		claims = Jwts.parser()
					.setSigningKey(secretKeyBytes)
					.parseClaimsJws(accessToken).getBody();
		
		Date expitration;
		Date now = DateUtil.getNowDate();
		String id;
		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();
		System.out.println(expitration);
		 
		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 (!ACCESS_TOKEN_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 (!ACCESS_TOKEN_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"));		
		}
		id = claims.getId();	
		User user = userService.selectUserByUserId(id);
		
		if (null == user) {
			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;
	}
	
	
	@Override
	public String refreshAccessToken(String refreshToken) throws Exception {
		User user = checkRefreshToken(refreshToken); 
		return getAccessToken(user);
	}
	
	@Override
	public String getRefreshToken(User user) throws Exception {
		if (null == user) {
			throw new CustomException(MessageProperties.prop("error.code.userId.invalid")
					, MessageProperties.prop("error.message.userId.invalid"));
		}
		
        SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
		byte[] secretKeyBytes = DatatypeConverter.parseBase64Binary(Base64.encodeBase64String(REFRESH_TOKEN_SECRET_KEY.getBytes()));
		Key signKey = new SecretKeySpec(secretKeyBytes, signatureAlgorithm.getJcaName());
		
		String strExpiration = DateUtil.addDate(DateUtil.getNow(DateUtil.YYYYMMDDHHmm), DateUtil.YYYYMMDDHHmm, Integer.parseInt(REFRESH_TOKEN_EXPIRE_DAY));
		Date expiration = DateUtil.getDate(strExpiration, DateUtil.YYYYMMDDHHmm);
		
		String userId = user.getUserId();
		
		if (null == userId && "".equals(userId)) {
			throw new CustomException(MessageProperties.prop("error.code.userId.invalid")
					, MessageProperties.prop("error.message.userId.invalid"));	
		}
		
	    JwtBuilder jwtBuilder = Jwts.builder()
	                .setId(userId) //jti(고유식별자)
	                .setAudience(REFRESH_TOKEN_AUDIENCE) // 토큰 대상자
	                .setIssuedAt(DateUtil.getNowDate()) //iat 발행날짜
	                .setSubject(REFRESH_TOKEN_SUBJECT) //sub 제목 
	                .setIssuer(REFRESH_TOKEN_ISSUER) // iss 발급자
	                .setExpiration(expiration) // exp 만료날짜
	                .signWith(signatureAlgorithm, signKey); // sign algo, key
	    
	    final String token =  jwtBuilder.compact();
	    
	    RefreshToken refreshToken = new RefreshToken();
	    refreshToken.setAuthentication(String.valueOf(user.getUserSeq()));
	    refreshToken.setToken(token);
	    
	    RefreshToken duplicationRefreshToken = refreshTokenDao.selectRefreshToken(refreshToken);
	    
	    int result = 0;
	    
	    if (null != duplicationRefreshToken) {
	    	refreshToken.setRefreshTokenSeq(duplicationRefreshToken.getRefreshTokenSeq());
	    	result = refreshTokenDao.updateRefreshToken(refreshToken);
	    } else {
	   	  	result = refreshTokenDao.insertRefreshToken(refreshToken);
	    }
	    
	    if (0 == result) {
	    	throw new CustomException(MessageProperties.prop("error.code.common.occured.exception")
					, MessageProperties.prop("error.message.common.occured.exception"));	
	    }
	    
		return token;
	}
	
	@Override
	public User checkRefreshToken(String refreshToken) throws Exception {
		if (null == refreshToken || "".equals(refreshToken)) {
			throw new CustomException(MessageProperties.prop("error.code.token.invalid")
					, MessageProperties.prop("error.message.token.invalid"));
		}
		
		byte[] secretKeyBytes = DatatypeConverter.parseBase64Binary(Base64.encodeBase64String(REFRESH_TOKEN_SECRET_KEY.getBytes()));
		Claims claims = null;
		claims = Jwts.parser()
					.setSigningKey(secretKeyBytes)
					.parseClaimsJws(refreshToken).getBody();
		
		Date expitration;
		Date now = DateUtil.getNowDate();
		String id;
		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 (!REFRESH_TOKEN_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 (!REFRESH_TOKEN_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"));		
		}
		id = claims.getId();	
		User user = userService.selectUserByUserId(id);
		
		if (null == user) {
			throw new CustomException(MessageProperties.prop("error.code.token.id.invalid")
					, MessageProperties.prop("error.message.token.id.invalid"));
		}	
		return user;
	}
}

Value 어노테이션을 사용하여 이전 과정에서 jwt.properties에 설정한 값들을 가져와서 멤버 변수에 주입합니다.
Access Token 은 6시간, Refresh Token은 30일에 만료 주기를 갖습니다.

SignatureAlgorithm은 HS256을 사용합니다.

  • getTokens 메서드
    액세스 및 리프레시 토큰 발행하는 메서드입니다.

  • getAccessToken 메서드
    JWT 액세스 토큰 생성하는 메서드입니다.

  • checkAccessToken 메서드
    JWT 액세스 토큰의 유효성 검사를 하는 메서드입니다.

  • refreshAccessToken 메서드
    JWT 액세스 토큰 재발행하는 메서드입니다.
    DAO를 호출하여 리프레시 토큰이 저장된 테이블을 조회하고
    중복이 있는 경우 토큰 정보만 업데이트하고 아닌 경우 정보를 저장합니다.

  • getRefreshToken 메서드
    JWT 리프레시 토근 발행하는 메서드입니다.

  • checkRefreshToken 메서드
    JWT 리프레시 토큰의 유효성 검사를 하는 메서드입니다.

14.3 User Service, Impl

package com.codejcd.service;

import com.codejcd.entity.User;

public interface UserService {
	/**
	 * 유저 조회
	 * @param userId
	 * @return
	 * @throws Exception
	 */
	public User selectUserByUserId(String userId) throws Exception;
	
	/**
	 * 유저 패스워드 확인
	 * @param userId
	 * @param password
	 * @return
	 * @throws Exception
	 */
	public User matchUserPassword(String userId, String password) throws Exception;
	
	/**
	 * 유저 등록
	 * @param userId
	 * @param userName
	 * @param password
	 * @return
	 * @throws Exception
	 */
	public int registUser(String userId, String userName, String password) throws Exception; 
}
package com.codejcd.service;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import com.codejcd.common.CustomException;
import com.codejcd.common.CustomPasswordEncoder;
import com.codejcd.common.MessageProperties;
import com.codejcd.dao.UserDao;
import com.codejcd.entity.User;

@Service
public class UserServiceImpl implements UserService{
	
	@Autowired
	private CustomPasswordEncoder passwordEncoder;

	@Autowired
	private UserDao userDao;
	
	@Override
	public User selectUserByUserId(String userId) throws Exception {
		return userDao.selectUserByUserId(userId);
	}
	
	@Override
	public User matchUserPassword(String userId, String password) throws Exception {
		User user = userDao.selectUserByUserId(userId);
		
		boolean result = passwordEncoder.matches(password, user.getPassword());
		if (!result) {
			throw new CustomException(MessageProperties.prop("error.code.userPassword.invalid")
					, MessageProperties.prop("error.message.userPassword.invalid"));	
		}
		
		return user; 
	}
	
	@Override
	public int registUser(String userId, String userName, String password) throws Exception {
		password = passwordEncoder.encode(password);
		return userDao.registUser(userId, userName, password);
	}
	
	
}

 

  • selectUserByUserId 메서드
    유저 ID를 매개변수로 받아서
    DAO를 호출하여 유저 정보를 조회하는 메서드.

  • matchUserPassword 메서드
    유저 정보를 매개변수로 받아서 DAO를 호출하여
    저장된 유저 정보와 비교하여 패스워드를 일치 여부를 확인하는 메서드.

  • registUser 메서드
    유저 정보를 매개변수로 받아서 DAO를 호출하여 등록하는 메서드.

15. Client, Token, User Controller

RequestMapping을 통한 Service 호출을 위한 클래스입니다.

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

 

15.1 Client Controller

package com.codejcd.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
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.ClientService;

/**
 * Client 컨트롤러
 */
@RestController
public class ClientController {

	@Autowired
	private ClientService clientService;
	
	/**
	 * Client 등록
	 * @param clientId
	 * @param clientSecret
	 * @return
	 */
    @RequestMapping("/client/regist")
    public Response clientRegist(@RequestParam(value="clientId", defaultValue="") String clientId,
    	@RequestParam(value="clientSecret", defaultValue="") String clientSecret) {
    	
    	Response response = new Response();
    	
    	try {
    		clientService.registClient(clientId, clientSecret);
        	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;
    }
}

 

  • /client/regist
    ClientService의 registClient를 호출하여 Request로 전달받은 Client 데이터를 매개변수로 넘겨줍니다.
    Exception이 없으면 정상 응답 코드가 세팅되고, Exception 발생 시 try~catch~finally 문에 의해 처리됩니다.
    이번 편에서는 별도의 테스트 데이터 SQL이 없으므로 해당 API를 호출하여 client 값을 생성합니다.

15.2 Token Controller

package com.codejcd.controller;

import java.util.HashMap;
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.RequestParam;
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.entity.User;
import com.codejcd.service.ClientService;
import com.codejcd.service.TokenService;
import com.codejcd.service.UserService;

@RestController
public class TokenController {

	@Autowired
	private TokenService tokenService;
	
	@Autowired
	private UserService userService;
	
	@Autowired
	private ClientService clientService;
	
	/**
	 * 토큰 발급
	 * token generation
	 * @param authorization
	 * @param userId
	 * @param password
	 * @return
	 */
    @RequestMapping("/oauth2/tokens")
    public Response generateToken(@RequestHeader(value="Authorization") String authorization,
    			@RequestParam(value="userId", defaultValue="") String userId,
    			@RequestParam(value="password", defaultValue="") String password) {
    	
    	Response response = new Response();
    	HashMap<String, Object> result = new HashMap<>(); 
    	
    	try {   
    		// Basic Auth 검증 
    		clientService.checkClientByClientId(authorization);
    		// User 검증
    		User user = userService.matchUserPassword(userId, password);
    		// 토큰 생성
        	result = tokenService.getTokens(user);
        	response.setResponseCode(MessageProperties.prop("error.code.common.success"));
    		response.setResponseMessage(MessageProperties.prop("error.message.common.success"));
    		response.setResult(result);
    	} 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;
    }
    
    /**
     * 토큰 연장
     * token refresh
     * @param authorization
     * @param refreshToken
     * @return
     */
    @RequestMapping("/oauth2/accessToken/refresh")
    public Response refreshToken(@RequestHeader(value="Authorization") String authorization,
			@RequestParam(value="refreshToken", defaultValue="") String refreshToken) {
    	
    	Response response = new Response();
    	HashMap<String, Object> result = new HashMap<>();
    	String accessToken = "";
    	try {
    		clientService.checkClientByClientId(authorization);
    		accessToken = tokenService.refreshAccessToken(refreshToken);
    		
        	response.setResponseCode(MessageProperties.prop("error.code.common.success"));
    		response.setResponseMessage(MessageProperties.prop("error.message.common.success"));
    		result.put("accessToken", accessToken);
    		response.setResult(result);
      	} catch(CustomException e) {
    		response.setResponseCode(e.getErrorCode());
    		response.setResponseMessage(e.getErrorMessage());
    	} catch(Exception e) {
            response.setResponseCode(MessageProperties.prop("error.code.common.occured.exception"));
            response.setResponseMessage(MessageProperties.prop("error.message.common.occured.exception"));
    	} finally {
    		
    	}
    	return response;
    }
}

 

  • /oauth2/tokens
    ClientService, userService를 호출하여 각각 Request로 전달받은 Client 정보와 User 정보를 검증한다.
    이후 tokenService의 getTokens 메서드를 user 정보를 매개 변수로 하여 호출하여
    액세스 토큰을 발급받아 리턴합니다. 

  • /oauth2/accessToken/refresh
    Client 정보와 리프레시 토큰을 Request로 전달받아 검증 후 유효하면 Access Token을 리턴합니다.

15.3 User Controller

package com.codejcd.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
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.UserService;

@RestController
public class UserController {
	
	@Autowired
	private UserService userService;

	/**
	 * 유저 등록
	 * @param userId
	 * @param userName
	 * @param password
	 * @return
	 */
    @RequestMapping("/user/regist")
    public Response clientRegist(@RequestParam(value="userId", defaultValue="") String userId,
    	@RequestParam(value="userName", defaultValue="") String userName,
    	@RequestParam(value="password", defaultValue="") String password) {
    	
    	Response response = new Response();
    	
    	try {
    		userService.registUser(userId, userName, password);
        	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;
    }
}

 

  • /user/regist
    Request로 전달받은 유저 데이터를 userService의 registerUser를 호출하여 매개변수로 전달하여
    유저 정보를 등록하고 결과를 리턴합니다. 
    이번 편에서는 유저 생성하는 SQL 이 없으므로 해당 API를 호출하여 유저 정보를 등록합니다.

 

16. 테스트 

Client와 User 정보 등록 후 Token이 정상 발급되는지 확인해보겠습니다.

테스트 툴로는 POSTMAN을 사용했습니다.

 

http://localhost:8095/client/regist API를 body 정보를 세팅하고 호출합니다.

정상적으로 처리되어 200 OK 응답과 함께 커스텀 코드와 메시지를 확인 가능합니다.

 

http://localhost:8095/user/regist API를 body 정보를 세팅하고 호출합니다.

정상적으로 처리되어 200 OK 응답과 함께 커스텀 코드와 메시지를 확인 가능합니다.

http://localhost:8095/oauth2/tokens API를 등록하지 않은 값을 세팅하고 호출해봅니다.

커스텀 Exception 처리한 메시지를 확인 가능합니다.

이번에는 잘못 등록한 값을 제대로 세팅하고 호출합니다.

정상적으로 토큰이 발급된 것을 확인 가능합니다.

header Basic Auth
Http Body
Http Resonse

 

블로그에서는 간단하게 테스트하는 방법을 보여드렸고,

실제로는 여러 가지 상황을 가정해서 테스트 케이스를 만들고
더 상세하게 테스트해봐야 합니다.


17. 마치며
이번 글에서는 Spring Security Oauth 없이 

Basic Auth에 대한 처리와 Access, Refresh Token을 생성하는 기능을 구현해봤습니다.
다음 글에서는 Resource Server를 구현해보고 이상 이번 시리즈를 마치겠습니다.

2부에 이어서 Resource Server를 구현하고 Authorization Server와

함께 토큰 발급과 토큰을 이용한 리소스 접근에 대한 테스트를 진행해보겠습니다.

 

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

2부에서 생성한 테이블과 테스트 데이터를 이용합니다.

 

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

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

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

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>2.1.6.RELEASE</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>
	<groupId>com.codejcd</groupId>
	<artifactId>SpringbootMybatisMysqlOauth2.0-ResourceServer</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>SpringbootMybatisMysqlOauth2.0-ResourceServer</name>
	<description>SpringbootMybatisMysqlOauth2.0-ResourceServer</description>

	<properties>
		<java.version>1.8</java.version>
	</properties>

	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
		<dependency>
			<groupId>org.mybatis.spring.boot</groupId>
			<artifactId>mybatis-spring-boot-starter</artifactId>
			<version>2.1.0</version>
		</dependency>

		<dependency>
			<groupId>mysql</groupId>
			<artifactId>mysql-connector-java</artifactId>
			<scope>runtime</scope>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
		<dependency>
			<groupId>org.springframework.security</groupId>
			<artifactId>spring-security-test</artifactId>
			<scope>test</scope>
		</dependency>
	    <dependency>
            <groupId>org.springframework.security.oauth.boot</groupId>
            <artifactId>spring-security-oauth2-autoconfigure</artifactId>
            <version>2.1.6.RELEASE</version>
        </dependency>
        <!-- @ConfigurationProperites 사용 시 클래스 패스 설정 -->
   		<dependency>
   			 <groupId>org.springframework.boot</groupId>
    		 <artifactId>spring-boot-configuration-processor</artifactId>
		</dependency>
	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
			</plugin>
		</plugins>
	</build>

</project>

 

2. Resource Server 프로젝트 구조

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

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

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

- com.codejcd.entity : 엔티티를 파일들을 위한 패키지

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

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


3. Mybatis 설정

MybatisConfiguration 클래스를 생성하고 Properties에서 읽어올 설정 값과

SQL Session과 Mapper 설정을 합니다.

2부에서 설정과 동일하여 별도의 추가 설명은 하지 않겠습니다.

package com.codejcd.config;

import javax.sql.DataSource;

import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.SqlSessionTemplate;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;

import com.zaxxer.hikari.HikariDataSource;
/**
 * Mybatis 설정
 * @author Jeon
 *
 */
@Configuration
@MapperScan(basePackages = "com.codejcd.*.dao.**", sqlSessionFactoryRef = "sqlSessionFactory")
public class MybatisConfiguration {

	@Bean(name = "dataSource")
	@ConfigurationProperties(prefix = "spring.datasource.hikari")
	public DataSource dataSource() {
		return new HikariDataSource();
	}
	
	@Bean(name = "sqlSessionFactory")
	public SqlSessionFactory sqlSessionFactory (
			@Qualifier("dataSource") DataSource dataSource,
			ApplicationContext applicationContext) throws Exception {
		
			SqlSessionFactoryBean sqlSessionFactory = new SqlSessionFactoryBean();
			sqlSessionFactory.setDataSource(dataSource);
			sqlSessionFactory.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("config/mapper/*.xml"));
			
		return sqlSessionFactory.getObject(); 
	}
	
	@Bean(name = "sqlSession")
	public SqlSessionTemplate sqlSession(SqlSessionFactory sqlSessionFactory) throws Exception {
		return new SqlSessionTemplate(sqlSessionFactory);
	}
	
}

 

4. application.properties

MySQL DB에 접근하기 위한 설정과 JWT 설정을 추가합니다.

테스트 설정이니 참고만 해주시고 절대로 상용에서 사용하면 안 되는 비밀번호입니다.

server.port=8094
spring.datasource.hikari.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.hikari.jdbc-url=jdbc:mysql://localhost:3306/oauth2?characterEncoding=UTF-8&serverTimezone=Asia/Seoul
spring.datasource.hikari.username=root
spring.datasource.hikari.password=1111
spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.connection-test-query=SELECT 1 FROM DUAL
spring.datasource.hikari.minimum-idle=5
spring.datasource.hikari.connection-timeout=100000
security.oauth2.signkey=testsignkey123!@
security.oauth2.client.client-id=testClientId
security.oauth2.client.client-secret=testSecret
security.oauth2.resource.token-info-uri=http://localhost:8093/oauth/check_token

 

이 세 값은 JWT 일 경우는 해당 사항이 없다.

JWT는 검증을 위해  Authorization Serve를 호출하는 과정이 없기 때문이다.

단 JWT 가 아닌 일반적인 token을 사용하는 경우에는 호출한다.

 

  • security.oauth2.signkey
    JWT에 사용할 sign key 설정 값으로 복호화에 사용.
    이 데모 앱에서는 대칭 키를 사용하기 때문에 동일합니다.

  • security.oauth2.client.client-id=testClientId
    client id이다. token 검증을 위한 호출 시 사용됩니다.

  • security.oauth2.client.client-secret=testSecret
    client secret. token 검증을 위한 호출 시 사용됩니다.

  • security.oauth2.resource.token-info-uri=http://localhost:8093/oauth/check_token
    token 검증을 위한 Authroization Server의 token 검증 URI를 설정한다.
    token 검증 시 사용된다.

5. Resource Server 설정

Oauth2 Resource 서버 설정을 구현한다.

EnableResourceServer 어노테이션과 ResourceServerConfigureAdaptor를 상속받아

간단하게 핵심적인 기능을 구현이 가능하다. 

package com.codejcd.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;

@Configuration
@EnableResourceServer
public class Oauth2ResourceServerConfiguration extends ResourceServerConfigurerAdapter {
	
	@Override
	public void configure(HttpSecurity http) throws Exception {
		    // http.headers().frameOptions().disable(); // X-Frame-Options 설정
	        http.authorizeRequests()
	        		// access token이 있어야 접근 가능
	                //.antMatchers("/user/list1").access("isAuthenticated()"); // 인증 받은 경우 해당 URI 에 접근 가능
	                .anyRequest().authenticated(); // 모든 요청은 인증 받아야 접근 가능
    }	
}

 

.antMatchers("/user/list 1").access("isAuthenticated()")와.anyRequest().authenticated() 설정에

따라서 뒤에서 테스트 해볼 결과가 달라질 수 있다.

모든 자원에 인증이 필요한 경우가 있을 수 있고
특정 자원만 인증이 필요한 경우가 있을 수 있으니

해당 설정은 그런 상황에서 리소스 접근에 대한 관리가 용이합니다.

 

6. User Entity 구현  

Authroization Server의 Entitiy와 동일합니다.

UserDetails를 구현하는 객체입니다.

package com.codejcd.entity;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

public class User implements UserDetails {
	
	private static final long serialVersionUID = -4591689732776493890L;

	private int userSeq;
	
	private String userId;
	
	private String password;
	
	private String name;
	
	private String status;
	
	private List<String> roles = new ArrayList<String>();

	
	@Override
	public String getUsername() {
		return userId;
	}
	
	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		return this.roles.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
		
	}
	
	@Override
	public String getPassword() {
		return password;
	}
	
	@Override
	public boolean isAccountNonExpired() {
		return true;
	}
	
	@Override
	public boolean isAccountNonLocked() {
		return true;
	}

	@Override
	public boolean isCredentialsNonExpired() {
		return true;
	}
	
	@Override
	public boolean isEnabled() {
		return true;
	}

	public int getUserSeq() {
		return userSeq;
	}

	public void setUserSeq(int userSeq) {
		this.userSeq = userSeq;
	}

	public String getUserId() {
		return userId;
	}

	public void setUserId(String userId) {
		this.userId = userId;
	}

	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}

	public List<String> getRoles() {
		return roles;
	}

	public void setRoles(List<String> roles) {
		this.roles = roles;
	}

	public void setPassword(String password) {
		this.password = password;
	}

	public String getStatus() {
		return status;
	}

	public void setStatus(String status) {
		this.status = status;
	}
}


7. User SQL Mapper 구현

User Data를 리스트 형태로 조회할 SQL을 작성합니다.

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 
<mapper namespace="com.codejcd.mapper.UserMapper">
     <select id="selectUserList" resultType="com.codejcd.entity.User">
     	 SELECT user_seq as userSeq
  				, user_id as userId
  				, password as password
  				, name as name
  				, status as status
  				, reg_date as regDate
  		  FROM user
     </select>
</mapper>

 

8. User DAO 구현

SQL Mapper에 접근하여 결과 값을 얻을 DAO를 작성합니다.

package com.codejcd.dao;

import java.util.List;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Repository;
import com.codejcd.entity.User;

@Repository
public class UserDao {
	private final static String NAMESPACE = "com.codejcd.mapper.UserMapper.";
	
	@Autowired
	@Qualifier("sqlSession")
	private SqlSessionTemplate sqlSession;
	  
	   public List<User> selectUserList() {
	    	return sqlSession.selectList(NAMESPACE + "selectUserList");
	   }
}

 

9. UserService 구현

UserService 레이어입니다. 

유저 리스트 조회를 위한 UserDao의 selectUserList 메서드를

호출하고 결과 값을 리턴합니다.

여기서 데모 앱이기 때문에 별다른 비즈니스 로직은 없습니다.

package com.codejcd.service;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import com.codejcd.dao.UserDao;
import com.codejcd.entity.User;

@Service
public class UserService {

	@Autowired
	private UserDao userDao;
	
	public List<User> selectUserList() {
		return userDao.selectUserList();
	}
}

 

10.  UserController 구현

RestContoller 어노테이션을 사용하여,

JSON  포맷으로 응답 값을 반환하게 설정합니다.

package com.codejcd.controller;

import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.codejcd.entity.User;
import com.codejcd.service.UserService;

@RestController
public class UserController {
	
	@Autowired
	private UserService userService;
	
    @RequestMapping("/user/list1")
    public List<User> getUserList1() {
    	return userService.selectUserList(); 
    }
    
    @RequestMapping("/user/list2")
    public List<User> getUserList2() {
    	return userService.selectUserList(); 
    }

}

 

  • /user/list1, /user/list2
    유저 리스트를 조회하는 같은 기능을 합니다,
    앞으로 돌아가서 Oauth2ResourceServerConfiguration를 주석 처리된 설정을 통해 
    어떻게 동작하는지 뒤이은 테스트에서 결과 값을 확인해봅니다.

11. 테스트

테스트 툴로는 POSTMAN을 사용했습니다.

먼저 앞에서 구현한 Authrozation Server를 시작하고 

Authrozation Basic에 클라이언트 정보와 Body 정보를 세팅하고 
http://localhost:8093/oauth/token를 호출하여 응답 값으로 토큰 값을 얻습니다.

 

header 정보 세팅

 

body 정보 세팅하고 API 호출

 

앞에서 작성한 자원 서버 API(/user/list1, /user/llist2) 를 먼저 토큰 값을 세팅하지 않고 호출해봅니다.

/user/list1 API 호출
/user/list2 API 호출

 

두 API 모두 접근 권한이 없으므로 Unauthorized 응답 값이 확인됩니다.

Bearer Token Header 값에 세팅하고 다시 두 API를 호출하면 접근 가능한 것을 확인 가능합니다.

/user/list1 API 호출

 

/user/list2 API 호출

 

.antMatchers("/user/list1").access("isAuthenticated()") 주석을 해제하고

.anyRequest().authenticated() 을 주석 처리하고 다시 시도하면 /user/list1은 접근 가능하고
/user/list2는 접근 불가능합니다.

 

12. 마치며

비교적 간단한 Spring Security Oauth2 설정을 사용하여,

password credential을 데모 앱을 구현하고
이에 따른 리소스 접근 제어를 확인해봤습니다.

아쉽지만 이 시리즈 작성을 시작할 때 이야기했듯이
지원 중단이 될 프로젝트이고 디테일한 설정을 하기 위해서는
Spring Security Oatuh2를 더 공부해야 가능할 것 같습니다.
다음은 이 시리즈의 마지막으로 Oauth2 + JWT를
Spring Security Oauth2 없이 직접 구현해보겠습니다.
전체 소스는 Github에서 확인 가능합니다.

※ 참고

https://projects.spring.io/spring-security-oauth/docs/oauth2.html

https://docs.spring.io/spring-security/site/docs/3.2.5.RELEASE/reference/htmlsingle/#el-access

저번 글에 이어서 Oauth 2.0를 구현해볼 예정입니다.

Spring Boot 기반 프로젝트를 생성하고 Spring Security Oauth를 사용하여 구현하는 방법과

Spring Security Oauth를 사용하지 않는 방법 2가지로 나눠서 소개드리려고 합니다.

본격 들어가기전에 앞서 먼저 최근 소식을 전해드리면 Spring Security Oauth 프로젝트는 

2019년 11월에 로드맵을 발표하면서 지원 종료 계획을 발표했습니다.

자세한 내용은 링크를 참고해주시구요.

제가 2019년 8월 경에 Spring Security Oauth Demo를 Github에 작성했었는데요.

지금 글을 작성하는 시점에서는 지원 종료될 예정에 프로젝트니 Spring Security Oauth 프로젝트에서

이렇게 구현을 했었구나 정도 참고만 하고 실무에서 사용하는 것은 권장하지 않습니다.

 

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

MySQL Community 5.6 이 미리 설치됐다는 가정하고 진행해보겠습니다.

먼저 Github에서 src/main/resources/db/scheme.sql 과 test_data.sql 을 실행하여

필요한 테이블 및 테스트 데이터를 생성합니다.

oauth_acces_token 테이블과 테스트 데이터는 JWT 방식이 아닐 경우

설정에서 필요한 데이터이기 때문에 JWT만 테스트할 경우 필수 생성하지 않아도 됩니다.

나머지 oauth_client_details 테이블의 client 정보와

user 테이블의 사용자 정보는 password credential 방식에서 필수 정보이므로 생성합니다.

 

1. 구현 내용

먼저 Spring Security Oatuh을 이용하여 Authorization Server와

Resource Server를 구현해볼 예정입니다.

유저 정보, 토큰을 저장/조회하기 위해 MySQL DB를 사용할 예정입니다.

물론 MySQL 이외에 다른 DB를 사용해도 되지만 실무에서 가장 많이 접하고 있는 게

현재 MySQL이라서 사용하게 됐습니다.

사용하기 편한 DB를 사용하면 되고, DB 구조는 알맞게 수정하면 됩니다.

본격 구현에 들어가기에 앞서 Spring Security Oauth 구조와 흐름에 대해서 간단하게 알아보겠습니다.

복잡해 보이지만 사용자 요청 → UsernamePasswordAuthenticationFilter 구현체

→ AuthenticationProvider 구현체 → UserDetailsService 구현체로 흘러가는 흐름입니다.

이중 Filter는 커스텀하지 않고 AuthenticationProvider, UserDetailsService, User를 커스텀하겠습니다.

 

 

 

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

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

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

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>2.1.6.RELEASE</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>
	<groupId>com.codejcd</groupId>
	<artifactId>SpringbootMybatisMysqlOauth2.0</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>SpringbootMybatisMysqlOauth2.0</name>
	<description>SpringbootMybatisMysqlOauth2.0</description>

	<properties>
		<java.version>11</java.version>
	</properties>

	<dependencies>
		<dependency>
			<!-- Spring Security 의존성 설정 -->
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-security</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
		<!-- mybatis 사용을 위한 설정 -->
		<dependency>
			<groupId>org.mybatis.spring.boot</groupId>
			<artifactId>mybatis-spring-boot-starter</artifactId>
			<version>2.1.0</version>
		</dependency>
		<!-- mysql connector 설정 -->
		<dependency>
			<groupId>mysql</groupId>
			<artifactId>mysql-connector-java</artifactId>
			<scope>runtime</scope>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
		<dependency>
			<groupId>org.springframework.security</groupId>
			<artifactId>spring-security-test</artifactId>
			<scope>test</scope>
		</dependency>
		<!-- oauth2.0 기능 구현을 위한 의존성 설정 -->
		 <dependency>
            <groupId>org.springframework.security.oauth.boot</groupId>
            <artifactId>spring-security-oauth2-autoconfigure</artifactId>
            <version>2.1.6.RELEASE</version>
         </dependency>
   		<!-- @ConfigurationProperites 사용 시 클래스 패스 설정 -->
   		<dependency>
   			 <groupId>org.springframework.boot</groupId>
    		 <artifactId>spring-boot-configuration-processor</artifactId>
		</dependency>

	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
			</plugin>
		</plugins>
	</build>

</project>

 

3. Authorization Server 프로젝트 구조

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

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

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

- com.codejcd.entity : 엔티티를 파알들을 위한 패키지

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

 

4. Mybtais 설정

MybatisConfiguration 클래스를 생성하고 Properties에서 읽어올 설정 값과

SQL Session과 Mapper 설정을 합니다.

package com.codejcd.config;

import javax.sql.DataSource;

import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.SqlSessionTemplate;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;

import com.zaxxer.hikari.HikariDataSource;
/**
 * Mybatis Configuration
 * @author Jeon
 *
 */
@Configuration
@MapperScan(basePackages = "com.codejcd.*.dao.**", sqlSessionFactoryRef = "sqlSessionFactory")
public class MybatisConfiguration {

	@Bean(name = "dataSource")
	@ConfigurationProperties(prefix = "spring.datasource.hikari")
	public DataSource dataSource() {
		return new HikariDataSource();
	}
	
	@Bean(name = "sqlSessionFactory")
	public SqlSessionFactory sqlSessionFactory (
			@Qualifier("dataSource") DataSource dataSource,
			ApplicationContext applicationContext) throws Exception {
		
			SqlSessionFactoryBean sqlSessionFactory = new SqlSessionFactoryBean();
			sqlSessionFactory.setDataSource(dataSource);
			sqlSessionFactory.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("config/mapper/*.xml"));
			
		return sqlSessionFactory.getObject(); 
	}
	
	@Bean(name = "sqlSession")
	public SqlSessionTemplate sqlSession(SqlSessionFactory sqlSessionFactory) throws Exception {
		return new SqlSessionTemplate(sqlSessionFactory);
	}
	
}

 

  • MapperScan Annotation
    basePackages의 지정된 com.codejcd.*.dao** 패키지에 대해 Mapper를 검색하여 등록합니다.
    SqlSessionFactoryRef로 별칭을 지정합니다.

  • ConfigurationProperties Annotation
    spring.datasource.hikari 로 시작하는DataSource 설정을
    application.properties 파일에 등록된 설정 정보를 주입합니다.

  • sqlSessionFactory Method
    SqlSessioFactoryBean을 생성하고 Mapper 파일들이 위치하는 경로를 설정한다.

  • sqlSession Method
    SqlSessionTemplate SqlSession을 구현하고 코드에서 SqlSession을 대체하는 역할을 합니다.
    SqlSessionTemplat e은 스레드에 안전하고 여러 개의 DAO나 Mapper에서 공유할 수 있습니다.
    그리고 세션의 생명주기를 관리하며, 스프링 트랜잭션에서 사용될 수 있도록 보장합니다.

5. application.properties

MySQL DB에 접근하기 위한 설정을 추가합니다.

테스트 설정이니 참고만 해주시고 절대로 상용에서 사용하면 안 되는 비밀번호입니다.

security.oauth2.signkey는 뒤에 내용에서 JWT에 사용할 설정 값입니다.

server.port=8093
spring.datasource.hikari.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.hikari.jdbc-url=jdbc:mysql://localhost:3306/oauth2?characterEncoding=UTF-8&serverTimezone=Asia/Seoul
spring.datasource.hikari.username=root
spring.datasource.hikari.password=1111
spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.connection-test-query=SELECT 1 FROM DUAL
spring.datasource.hikari.minimum-idle=5
spring.datasource.hikari.connection-timeout=100000
security.oauth2.signkey=testsignkey123!@

 

6. UserDetails 

6.1 User 구현 

1번의 구조 이미지에서 User에 해당하는 부분이며, UserDetails를 구현하는 객체입니다.

오버 라이딩 메서드 이외에 생성할 DB 칼럼에 맞춰 필요한 값들을 추가해줍니다.

package com.codejcd.entity;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

public class User implements UserDetails {
	
	private static final long serialVersionUID = -4591689732776493890L;

	private int userSeq;
	
	private String userId; 
	
	private String password;
	
	private String name;
	
	private String status;
	
	private List<String> roles = new ArrayList<String>();

	
	public int getUserSeq() {
		return userSeq;
	}

	public void setUserSeq(int userSeq) {
		this.userSeq = userSeq;
	}
	
	@Override
	public String getUsername() {
		return userId;
	}
	
	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		return this.roles.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
		
	}
	
	@Override
	public String getPassword() {
		return password;
	}
	
	@Override
	public boolean isAccountNonExpired() {
		return true;
	}
	
	@Override
	public boolean isAccountNonLocked() {
		return true;
	}

	@Override
	public boolean isCredentialsNonExpired() {
		return true;
	}
	
	@Override
	public boolean isEnabled() {
		return true;
	}

	public String getUserId() {
		return userId;
	}

	public void setUserId(String userId) {
		this.userId = userId;
	}

	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}

	public List<String> getRoles() {
		return roles;
	}

	public void setRoles(List<String> roles) {
		this.roles = roles;
	}

	public void setPassword(String password) {
		this.password = password;
	}

	public String getStatus() {
		return status;
	}

	public void setStatus(String status) {
		this.status = status;
	}
}

 

6.2 User SQL Mapper 구현

User 객체에 필요한 값들을 userId를 조건으로 조회할 SQL Mapper을 작성합니다.

이번 데모에서는 Mybatis를 이용하기 때문에 필요한 작업입니다.

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 
<mapper namespace="com.codejcd.mapper.UserMapper">
     <select id="selectUser" parameterType="String" resultType="com.codejcd.entity.User" >
  		SELECT user_seq as userSeq
  				, user_id as userId
  				, password as password
  				, name as name
  				, status as status
  				, reg_date as regDate
  		  FROM user
  		WHERE user_id = #{userId}  	 
     </select>
</mapper>

 

6.3 User DAO 구현

SQL Mapper에 접근하여 결과 값을 얻을 DAO를 작성합니다.

package com.codejcd.dao;

import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Repository;

import com.codejcd.entity.User;

@Repository
public class UserDao {
	private final static String NAMESPACE = "com.codejcd.mapper.UserMapper.";
	
	@Autowired
	@Qualifier("sqlSession")
	private SqlSessionTemplate sqlSession;
	
	   public User selectUserByUserId(String userId) {
	    	return sqlSession.selectOne(NAMESPACE + "selectUser", userId);
	   }

}

 

  • selectuserByUserId Method
    userId를 매개변수로 받아 UserMapper에 맵핑된 SQL 조회한 결과 값을 리턴합니다.

6.4 UserDetailService 구현

1번의 구조 이미지에서 UserDetailService에 해당하는 부분으로

UserDetailService 구현하는 클래스입니다.

loadUserByUsername 메서드를 오버 라이딩합니다.

package com.codejcd.service;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import com.codejcd.dao.UserDao;

@Service
public class CustomUserDetailService implements UserDetailsService {
	
	@Autowired
	private UserDao userDao;
	
	@Override
	public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
		return userDao.selectUserByUserId(userId);
	}

}

 

7. AuthenticationProvider 구현

AuthenticationProvider의 구현하는 클래스입니다.

authenticate 메서드를 오버 라이딩하여 어떻게 인증 처리를 할지를 구현합니다.

유저/패스워드에 대한 간단한 유효성 체크 후 인증 통과 시

UsernamePasswordAuthenticationToken 객체 인스턴스를 리턴합니다.

현업에서는 유저 아이디에 대한 생성 규칙이나 패스워드 정책에 따라

더욱 꼼꼼하게 체크를 해야 하지만 데모 앱이므로 간략화했습니다.

package com.codejcd.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import com.codejcd.service.CustomUserDetailService;

/**
 * AuthenticationProvider Impl
 * @author Jeon
 *
 */
@Component
public class CustomAuthenticationProvider implements org.springframework.security.authentication.AuthenticationProvider {
	
	@Autowired
	private PasswordEncoder passwordEncoder; 
	 
	@Autowired
	private CustomUserDetailService custumUserDetailService;
	
	@Override
	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
		
		String userId = authentication.getName();
		String password = authentication.getCredentials().toString();
		
		UserDetails userDetail = custumUserDetailService.loadUserByUsername(userId);
	
		if(null == userDetail) {
			throw new BadCredentialsException("user is null");
		}
		
		if (!passwordEncoder.matches(password, userDetail.getPassword())) {
			throw new BadCredentialsException("password is invalid");
		}
		
		return new UsernamePasswordAuthenticationToken(userId, password, userDetail.getAuthorities());
	}
	
	@Override
	public boolean supports(Class<?> authentication) { // false 경우 authenticate 메소드 호출하지 않음.
		return authentication.equals(UsernamePasswordAuthenticationToken.class);
	}

}

 

8. WebMvcConfiguration

WebMvcConfigurer 구현체이며, WebApplicationContext에 사용할 설정이다.

이번 데모 앱에서는 특별한 설정을 넣지 않았고, CORS 설정 관련해서는 주석 처리했습니다.

package com.codejcd.config;

//import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
//import org.springframework.security.crypto.factory.PasswordEncoderFactories;
//import org.springframework.security.crypto.password.PasswordEncoder;
//import org.springframework.web.client.RestTemplate;
//import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {
	
	//private static final long MAX_AGE_SECONDS = 3600;

	/*
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("*")
                .allowedMethods("GET", "POST", "PUT", "DELETE")
                .allowedHeaders("*")
                .allowCredentials(true)
                .maxAge(MAX_AGE_SECONDS);
    }
	 */

}

 

9. SecurityConfiguration

WebSecurityConfigurerAdapter 구현체이며, 보안 구성을 설정입니다.

package com.codejcd.config;

import org.springframework.beans.factory.annotation.Autowired;
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.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;

/**
 * Spring Security Configuration
 * @author Jeon
 *
 */
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
	
	@Autowired
	private CustomAuthenticationProvider customAuthenticationProvider; 

	@Override
	protected void configure(AuthenticationManagerBuilder auth) throws Exception {
		auth.authenticationProvider(customAuthenticationProvider);
	}
	
	@Bean
	@Override
	protected AuthenticationManager authenticationManager() throws Exception {
		return super.authenticationManager();
	}
	
    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
    
}

 

  • configure Method
    |7번에서 작성한 CustomAuthenticationProvider를 AuthenticationManger 구현체로 설정하고 Bean으로 등록합니다.

  • authenticationManager Method
    변경 없이 그대로 상속받습니다.

  • passwordencdoer Method
    PasswordEncoderFactories.createDelegatingPasswordEncoder()로 생성한 PasswordEncoder는
    값의 prefix인 {id}로 PasswordEncoder 유형에 따라 정의됩니다.
    이번 데모에서 사용하는 테스트 데이터(test_data.sql)에는 bcrypt를 사용합니다.

10. Oauth2 AuthServerConfiguration

AuthorizationServerConfigurerAdapter 구현체이며, 인증 방식에 대해 설정합니다.

JWT 방식에 대해 설정하고 있는데 JWT가 설정하지 않을 경우 Access Token을 획득한 사용자가

Resource Server에 접근하려고 Access Token을 전달했을 경우에 Resource Server는

다시 Authorization Server에 유효한 토큰이 맞는지 체크를 해야 합니다.

JWT 설정한 경우에는 Resource Server에 요청이 들어올 경우 JWT 검증하여 유효한 경우

Authorization Server에 체크하지 않습니다.

유효성은 sign key를 이용하여 복호화를 통해 검증합니다.

package com.codejcd.config;

import javax.sql.DataSource;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import com.codejcd.service.CustomUserDetailService;

/**
 * Oauth2 Authorization Server Configuration
 * @author Jeon
 *
 */
@Configuration
@EnableAuthorizationServer
public class Oauth2AuthServerConfiguration extends AuthorizationServerConfigurerAdapter {
	
	@Autowired
	private CustomUserDetailService customUserDetailService;  
	
	@Autowired
	private AuthenticationManager authenticationManager; 
	
	@Autowired
    private DataSource dataSource;
	
	@Autowired
	private PasswordEncoder passwordEncoder; // DI가 없는 이유는 DB 저장시 Prefix를 사용하여 다양한 암호화 방식을 지원한다. ex) {bcrypt}
	
	@Value("${security.oauth2.signkey}")
	private String signKey;
    
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.jdbc(dataSource) // Client 인증 시 사용할 datasoruce 설정
        	   .passwordEncoder(passwordEncoder); // 패스워드 인코딩 섷정

    }	
    
    /** 
    * 인증과 토큰 Endpoint 설정
    *  유저 토큰 인증 설정 및 토큰에 대해 DB 사용
    */
    /*@Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
    	 endpoints.tokenStore(new JdbcTokenStore(dataSource)) // 토큰 DB 설정 
    	 .userDetailsService(custumUserDetailService) // 유저 인증 관리자 및 서비스
    	 .authenticationManager(authenticationManager);  
    }*/
    
    /**
     *  인증과 토큰 Endpoint 설정
     *  JWT와 유저 토큰 인증 설정
     */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
    	super.configure(endpoints);
    	endpoints
    	.userDetailsService(customUserDetailService) // 유저 인증 관리자 및 서비스
    	.authenticationManager(authenticationManager)
    	.accessTokenConverter(jwtAccessTokenConverter()); // Json Web Token 변환 설정
    }
    
    /**
     * Json Web Token 설정
     * @return
     */
    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
    	JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
    	converter.setSigningKey(signKey); // 사인 키 설정
    	return converter;
    }
    
    /**
     * 토큰 보안 제약
     */
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
    	 security.tokenKeyAccess("permitAll()")
            .checkTokenAccess("isAuthenticated()");
           // .allowFormAuthenticationForClients();
    }
    

}

 

  • configure(ClientDetailsServiceConfigurer clients) Method
    API 요청 클라이언트 세부 정보를 정의.

  • configure(AuthorizationServerEndpointsConfigurer endpoints) Method
    Endpoint 에 대한 설정. TokenStore 또는 JWT 설정 등 토큰 서비스를 정의.
    해당 데모 앱에서는 JWT 설정, userDetailService, authenticationManager를 설정하였습니다.

  • configure(AuthorizationServerSecurityConfigurer security) Method
    Token Endpoint에 대한 액세스 규칙 등 보안 제약 조건을 정의.
    permitAll()은 모두에게 허용이며, isAuthenticated()는 익명이 아닌 인증된 사용자만 허용.
    pertmitAll()이라고 해도 client가 아닌 경우 API를 호출한다고 token key를 보여주지는 않는다.
    자세한 권한에 대한 내용은 해당 링크의 3.1.1 항목을 참고해주기 바랍니다.

11.  테스트

미리 생성해둔 테스트 정보로 테스트를 진행해보겠습니다.

테스트 툴로는 POSTMAN을 사용했습니다.

Header 에는 Authorization에 Basic Auth 타입으로 Client 정보를 입력해주고

Body 에는 client_id, grant_type, username, password를 Client ID, 인증 방식, 유저 정보를 입력합니다.

Response에 정상적으로 token 정보가 응답 오는 것을 확인 가능합니다.

만약 틀린 정보를 입력하면 다른 응답 값을 리턴하며,

Basic Auth가 틀린 경우 401 응답 코드를 리턴 받게 됩니다.

 

header 정보 세팅
body 정보 세팅

 

응답 값

13. 마치며

생각보다 내용이 길어져서 Resource Server에 대한 구현은 3부에 이어서 진행하겠습니다.

서두에서 이야기했지만 Spring Security Oauth가 이미 지원 종료되는

프로젝트이기 때문에 자세히 설명하지 않았습니다.

어떤 방식으로 Oauth 2.0을 구현을 했는지 구조와 방법을 어느 정도 익힌다면

직접 구현도 어렵지 않을 것으로 보이며, 대체하는 여러 오픈 소스가 있으니

오픈 소스마다 구현 방법이 조금씩 다를 순 있어도 결국에는 인증 방식이라던지

큰 틀은 벗어나지 않을 것이라 생각됩니다.

전체 소스는 링크를 참고해주세요.

 

※ 참고

https://projects.spring.io/spring-security-oauth/docs/oauth2.html

https://docs.spring.io/spring-security/site/docs/3.2.5.RELEASE/reference/htmlsingle/#el-access

이번에 정리해볼 내용은 Oauth 2.0과 JWT(Json Web Token)입니다.

Spring Boot를 사용하여 구현해볼 예정입니다.

여담으로 실무에서 Oauth 2.0을 먼저 접해보았습니다.

이전 회사에서는 주로 웹 서비스만 다루는 경우가 많아서 Session, Cookie를 사용하여 인증했고

이번 회사에서 일하면서 처음으로 접하게 되면서 공부를 하게 되었습니다.

공부한지는 이미 한참이 되었으나 그동안 하드 드라이브에  Github에 업로드만하고

정리를 못한 저장된 소스를 꺼내어 간단하게 이론

먼저 1부에서 정리한 후에 2부에서 Demo를 만들어보겠습니다.

 

1.Oauth 란?

Oauth는 사용자들이 비밀번호를 제공하지 않고

다른 웹사이트 상의 자신들의 정보에 대해 웹사이트나 애플리케이션의

접근 권한을 부여할 수 있는 공통적인 수단으로써 사용되는 접근 위임을 위한 개방형 표준입니다.

예를 들면 네이버, 카카오, 구글 아이디로 로그인 같은 서비스를 생각해보면 됩니다.

Oauth 2.0 이 널리 쓰이고 있고 1.0 버전은 웹 이외에 애플리케이션 지원이 부족한 점과 Access Token의

만료가 없는 점 등 여러 가지 단점 때문에 개선된 2.0 버전을 사용하게 됐습니다.

 

2. Oauth 2.0 구조와 흐름

명칭 설명
Resource Owner 보호 자원에 접근을 부여할수 있는 개체
ex) 서비스 이용자
Resource Server 보호 자원에 API는 제공하는 서버
예) 로그인 
Authorization Server 자원 접근 권한을 위임 및 관리 서버
Client Resource Server에 보호 자원을 요청하고 관련 서비스를
제공하는 애플리케이션 
Access Token  자원 접근에 대한 권한을 나타내는 자격 증명
Refresh Token Access Token 만료 시 이를 갱신하기 위한 용도로 사용하는 자격 증명

 

3. Oauth 2.0 승인 방식 종류

명칭 설명
Authorization Code Grant Type 권한 부여 코드 승인 타입.
리소스 접근을 위한 아이디/패스워드를 Authorization
서버에 요청하여 받은 권한 코드를 활용하여 리소스에
대한 access token을 획득하는 방식.
Implicit Grant Type 암묵적 승인.
첫번째 타입과 흐름이 비슷합니다.
인증 후 redirect url 로 직접 access token을 전달받음.
Resource Owner Password Credentials Grant Type 리소스 소유자 암호자격 증명타입.
Resource Owner가 직접 Client에 아이디/패스워드 입력하여
Authorization 서버에 인증받고 access token을 얻는 획득하는 방식.
Client Credentials Grant Type 클라이언트 자격 증명 타입
access token을 얻기 위해 정해진 인증 key(secret)으로 요청.
일반적인 사용보다는 서버간 통신에 주로 사용.

 

3.1. Authorization Code Grant Type


3.2. Implicit Grant Type


3.3. Resource Owner Password Credentials Grant Type


3.4. Client Credentials Grant Type

 

4. JWT(JSON Web Token)

JWT는 일반적으로 클라이언트와 서버, 서비스와 서비스 사이 통신 시 권한 인가를 위해 사용하는 토큰이다. 

URL 안전한 문자로 구성되고 HTTP Header, Body, URL 등에 위치할 수 있습니다.

헤더, 페이로드, 서명 세 부분을 점(.)으로 구분하는 구조입니다.

 

4.1 Header

JWT 검증에 필요한 정보를 가진 JSON 객체를 BASE64 URL-Safe 인코딩한 문자열.

JWT를 어떻게 검증하는지에 대한 내용을 담는다.

 

4.2 Payload

Payload 속성을 Claim Set이라고 부른다. Claim Set은 JWT 대한 생성자, 생성 일자 등 클라이언트와 서버 간

주고받기로 한 값들로 구성.

속성 설명
iss 토큰을 발급한 발급자(Issuer)
sub Claim의 주제로 토큰이 갖는 문맥을 의미
aud 이 토큰을 사용할 수신자
exp 토큰 만료시간
nbf Not Before의 의미로 이 시간 이전에는 토큰을 처리하지
않아야 함을 의미
iat 토큰이 발급된 시간(Issued At)
jti JWT ID로 토큰에 대한 식별자이다.

 

4.3 Signature

점(.)을 구분자로 헤더와 페이로드를 합친 문자열을 서명한 값. 

헤더에 정의된 알고리즘 값과 비밀 키를 이용해 생성하고 Base64 URL-Safe 인코딩한다.

 

JWT는 점(.)을 구분자로 해서 Header, Payload, Signature를 합친 것이다.

간단하게 샘플 JWT을 생성해보고 싶으시다면 아래 URL의 툴을 이용해서 한번 생성해보세요.

https://jwt.io/#debugger

 

5. 마치며

간략하게 이론적인 부분을 정리해보았습니다.

가장 정확한 것은 RFC 문서이지만 다른 자료들을 참고해서 요약정리해봤습니다.

2부에서는 Spring Boot 기반으로 직접 구현해볼 예정입니다.

잘못된 내용이나 문제가 될 소지가 있는 내용이 있으면 언제든지 연락 주세요.

 

※ 참고

https://datatracker.ietf.org/doc/html/rfc6749

https://tools.ietf.org/html/rfc7519
https://tools.ietf.org/html/rfc7515
https://tools.ietf.org/html/rfc7516

https://blog.naver.com/mds_datasecurity/222182943542

https://meetup.toast.com/posts/239

이번 글에서는 Spring Boot  + Spring Data JPA + MySQL 데모 앱을 만들겠습니다.

전형적인 MVC 패턴을 가진 구조의 코드에 CRUD 가능한 WEB API 데모를 제작해보겠습니다.

 

개발 환경 : STS 4.8.1, JDK 11, Spring Boot 2.6.2, MySQL Community 5.6, Maven 4.0.0

MySQL Community 5.6 이 미리 설치됐다는 가정하고 진행해보겠습니다.

 

들어가기전에 앞서서 간단하게 JPA와 관련된 ORM 개념을 간단하게 정리해보면 아래와 같습니다.

 

ORM(Object-Relation Mapping)은 객체와 데이터베이스 데이터를 맵핑 해주는 것으로

객체 관계를 바탕으로 SQL을 생성합니다.

 

JPA(Java Persistence API)로 JAVA에서 제공하는 API 이고 자바 어플리케이션에서

관계형 데이터베이스를 사용하는 방식을 정의한 인터페이스입니다. 

Spring Data JPA는 Hibernate를 구현체로 사용하고 있습니다.

 

1. Spring Boot 프로젝트 생성 

Spring Stater Project 생성하고 중간 의존성 설정 단계에서 아래와 같이

MySQL Driver와 Spring Data JPA를 설정하고 생성합니다.

프로젝트, 패키지 명은 적절하게 설정해주세요.

저는 SpringbootJPADemo, com.codejcd.demo 라고 설정했습니다.

아래와 같은 구조를 가진 프로젝트가 생성된 것을 확인할수있습니다.

자동 생성된 SpringbootJPADemoApplication.java 파일을 빼고는 모두 이번 Demo에서 만들 패키지와 파일들입니다.

 

 

2. application.properties 파일 설정

1번째 라인은 테스트 시 WEB API에 접근하기 위한 포트 설정 값입니다.

3~5 번째 라인은 MySQL DB에 접근하기 위한 설정 값입니다.

자신의 환경에 맞춰서 작성하면됩니다.

참고로 실무에서는 root 유저를 사용하지 않고 따로 제한된 권한을 가진 생성된 유저를 사용하며,  

비밀번호 역시 저렇게 간단하게 설정하지 않습니다. 테스트 용이니 참고만 하시기 바랍니다.

8번째 라인은 하이버네트 자동 키 생성 전략을 OFF 했습니다.

저같은 경우 해당 데모에서는 MySQL 테이블에 AUTO_INCREMENT 설정을 했기 때문입니다.

11번째 라인은 DB에 전송하는 SQL을 보기 위한 옵션입니다.

 

3. DB 스키마 생성

MySQL에 아래와 같은 스키마를 생성합니다.

생성 SQL은 Github 소스에서 create_user_table_.sql 을 참고해서 생성해주세요.

 

4. User 생성

com.codejcd.demo.bean 패키지와 User 파일을 생성하고 아래와 같이 작성합니다.

P.K 역할을 할 id, name, phone의 간단한 객체를 생성합니다.

package com.codejcd.demo.bean;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@Entity
public class User {
	@Id // 해당 프로퍼티가 Primary Key
	@GeneratedValue(strategy = GenerationType.IDENTITY) // Primary Key 생성을 DB에 위임
	private Long id;
	
	@Column(length = 20, nullable = false) // 길이 20 제한, not null
	private String name;
	
	@Column(length = 20, nullable = false, unique = true) // 길이 20 제한, Not Null, Unique key
	private String phone;

	public Long getId() {
		return id;
	}

	public void setId(Long id) {
		this.id = id;
	}

	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}

	public String getPhone() {
		return phone;
	}

	public void setPhone(String phone) {
		this.phone = phone;
	}
}

5. UserRepository

데이터 접근을 위한 JpaRepository를 상속 받는 인터페이스를 생성합니다.

User 데이터 조회를 위한 findBy* 메소드를 구현합니다.

package com.codejcd.demo.repository;

import java.util.List;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import com.codejcd.demo.bean.User;

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
	public List<User> findByName(String name);
	public List<User> findByPhone(String phone);
	
}

 

JpaRepository를 상속 받으면서 CRUD 관련 아래와 같은 기능들을 사용할수있습니다.

메소드  기능
 save()  레코드 저장 (insert, update)
 findOne()  primary key로 레코드 한건 찾기
 findAll()  전체 레코드 불러오기. 정렬(sort), 페이징(pageable) 가능
 count()  레코드 갯수
 delete()  레코드 삭제

규칙에 맞는 메소드를 인터페이스 상에 정의하면 아래와 같은 기능으로도 활용 가능합니다.

자세한 활용 방법은 참고 사이트에 링크한 JPA 레퍼런스 문서를 확인해주세요.

메소드 설명 
 findBy로 시작  쿼리를 요청하는 메소드
 countBy로 시작  쿼리 결과 레코드 수를 요청하는 메소드
메소드 포함 키워드  샘플  설명
 And  findByEmailAndUserId(String email, String userId)  여러필드를 and 로 검색
 Or  findByEmailOrUserId(String email, String userId)  여러필드를 or 로 검색
 Between  findByCreatedAtBetween(Date fromDate, Date toDate)  필드의 두 값 사이에 있는 항목 검색
 LessThan  findByAgeGraterThanEqual(int age)  작은 항목 검색
 GreaterThanEqual  findByAgeGraterThanEqual(int age)  크거나 같은 항목 검색
 Like  findByNameLike(String name)  like 검색
 IsNull  findByJobIsNull()  null 인 항목 검색
 In  findByJob(String … jobs)  여러 값중에 하나인 항목 검색
 OrderBy  findByEmailOrderByNameAsc(String email)  검색 결과를 정렬하여 전달

 

6. UserService

비즈니스 로직을 구현할 클래스를 생성합니다.

CRUD를 위한 메소드를 각각 생성합니다.

package com.codejcd.demo.service;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.codejcd.demo.bean.User;
import com.codejcd.demo.repository.UserRepository;


@Service
@Transactional
public class UserService {
    @Autowired
    UserRepository userRepository;
    
	public List<User> findByName(String name) {
		return userRepository.findByName(name);
	}
	
	public void save(User user) {
		userRepository.save(user);
	}
	
	public List<User> findAll() {
		return userRepository.findAll();
	}
	
	public void delete(User user) {
		userRepository.delete(user);
	}
	
}

 

7. UserController

Restful 컨트롤러를 생성하기 위해 @RestController 어노테이션을 추가합니다.

@RequestMapping 어노테이션을 추가해 각각 요청을 맵핑합니다.

package com.codejcd.demo.controller;
import java.util.List;


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

import com.codejcd.demo.bean.User;
import com.codejcd.demo.service.UserService;

@RestController
public class UserController {
	
    @Autowired
    UserService userService;
    
    @RequestMapping("list")
    public List<User> userlist() {
    	return userService.findAll();
    }
    
    @RequestMapping("name/{name}")
    public List<User> userFindByName(@PathVariable String name) {
    	return userService.findByName(name);
    }
    
    @RequestMapping("insert")
    public User userInsert(@ModelAttribute User user) {
    	userService.save(user);
    	return user;
    }
    
    @RequestMapping("delete")
    public User userDelete(@ModelAttribute User user) {
    	userService.delete(user);
    	return user;
    }
    
    @RequestMapping("update")
    public User userUpdate(@ModelAttribute User user) {
    	userService.save(user);
    	return user;
    }

}

8. API 호출을 통한 CRUD 테스트 

STS Boot Dashboard에서 서버를 스타트하고 UserController에 정의한 각각의 WEB API를 호출하면서

로그와 출력 결과를 확인해봅니다.

test1 유저 등록&amp;nbsp;


test2 유저 등록&amp;nbsp;


유저 리스트 조회


test1 유저 조회


test2 유저 업데이트


test2 유저 삭제


유저 리스트 조회

 


위 로그는 2번 항목에서 설정으로 객체 관계로 생성된 SQL 이 출력되고 있습니다.

 

9. 마치며

간단하게 JPA를 사용한 CRUD Demo를 작성해보았는데요.

실제 실무 환경에서는 이보다 훨씬 복잡한 객체 관계가 요구되기때문에 복잡한 코딩이 요구될 것 같습니다.

대부분 오래전 모델링된 DB를 쓰다보니 정규화도 제대로 안되어 있고 DBA가 없는 환경에서

여러 개발자들이 거치면서 비즈니스 롤에 따라 무분별하게 컬럼들이 추가된 경우가 많다보니

실제로 JPA를 기존 시스템 환경에 도입하려면 많은 리소스와 문제점이 예상됩니다.

조금 다른 이야기를 하자면 한국 시장에서 대부분의 시스템은 Mybatis Framework를 사용한 SQL Mapper 환경입니다.

실무 환경에서는 거의 Mybatis Framework를 사용한 SQL Mapper 환경에서

일을 하고 있습니다.

자바를 사용하는 실무자 입장에서는 아무래도 객체지향적인 프로그래밍을 하는데

JPA가 좋아보이는데요.

그럼에도 불구하고 한국 시장에서는 JPA 기술 스택을 원하는 업체가 그리 많지는 않습니다.

개인적인 생각이긴 하지만 몇가지 원인을 꼽자면 이미 기존 구축된 시스템이

Mybatis 기반이다보니어쨌든 일반 회사의 의사결정자 입장에서보면

굳이 잘 돌아가는 시스템을 리소스를 넣어서 갈아엎을 이유가 없기 때문이겠죠.

그렇다고 신규 시스템에 JPA를 도입할지 생각해본다면 굳이 구하기 쉽고

상대적으로 낮은 단가를 가진 SQL Mapper에 능숙한 개발자들이 많은데

JPA 스택을 가진 개발자들을 데리고 오고 싶을까 하는 생각이듭니다.

그래도 기회가 된다면 JPA 환경에 실무에서 일해보고 싶은 개인적인 바램입니다.

Github에서 전체 소스를 확인 가능합니다.

 

※ 참고

https://www.baeldung.com/hibernate-identifiers

https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#jpa.auditing

https://www.javaguides.net/p/spring-data-jpa-tutorial.html

이번 글에서는 지난 Spring Boot  + Spring Data MongoDB + MongoRepository Demo 에 이어서

MongoTemplate 를 사용하여 데모 앱을 만들겠습니다.

MongoRepository 과 비교할수 있게 작성해볼 예정이고 어떤 차이가 있는 정리해보겠습니다. 

 

개발 환경 : STS 4.8.1, JDK 11, Spring Boot 2.6.2 Mongo 5.0.5 Community, Maven 4.0.0

 

1. Spring Boot 프로젝트 생성 

Spring Stater Project 생성하고 중간 의존성 설정 단계에서 아래와 같이 설정하고 생성합니다.

프로젝트, 패키지 명은 적절하게 설정해주세요.

저는 SpringbootMongoDbDemo, com.example.demo 라고 설정했습니다.

 

아래와 같은 구조를 가진 프로젝트가 생성된 것을 확인할수있습니다.

Customer 파일은 다음 과정에서 생성할 패키지와 파일들입니다.

 

2. application.properties 파일 설정

설치된 MongoDB는 테스트를 위한 기본 버전으로 username과 password 설정이 없어서 주석처리했습니다.

별도의 Mongo 설정 없이 아래의 설정만으로도 MongoDB에 액세스 가능합니다.

보통 기본 uri가 localhost를 많이 쓰고 database는 미리 test로 생성했습니다.

spring.data.mongodb.uri=mongodb://localhost:27017
spring.data.mongodb.database=test
#spring.data.mongodb.username=
#spring.data.mongodb.passwrod=

 

3. Customer 객체 생성

Spring boot에서 Mongo를 사용 가능한 방법은 스프링 가이드를 참고해보면

크게 2가지 정도인데, 간단한 CRUD 처리는 MongoRepository를 사용하면 되고

조금 복잡한 작업을 위해서는 MongoTemplate 사용하면 됩니다.

이번 글에서는 MongoRepository 로 간략한 CRUD 테스트를 진행해보겠습니다.

package com.example.demo;

import org.springframework.data.annotation.Id;

public class Customer {

	@Id
	public String id;

	public String firstName;
	public String lastName;

	public Customer() {}

	public Customer(String firstName, String lastName) {
		this.firstName = firstName;
		this.lastName = lastName;
	}

	public String getFirstName() {
		return firstName;
	}

	public void setFirstName(String firstName) {
		this.firstName = firstName;
	}

	public String getLastName() {
		return lastName;
	}

	public void setLastName(String lastName) {
		this.lastName = lastName;
	}

	@Override
	public String toString() {
		return String.format(
				"Customer[id=%s, firstName='%s', lastName='%s']",
				id, firstName, lastName);
	}
}

4. CommandLineRunner 생성 및 서버 스타트

자동 생성된 SpringbootMongoDbDemoApplication2.java 파일로 이동하여 CommandLineRunner 구현해줍니다.

Overide된 run 메소드 안에 CRUD 테스트 코드를 작성하고 Boot Dashboard에서 서버를 Start 합니다.

package com.example.demo;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;

@SpringBootApplication
public class SpringbootMongoDbDemo2Application implements CommandLineRunner {

	@Autowired
	private MongoTemplate mongoTemplate;
	
	public static void main(String[] args) {
		SpringApplication.run(SpringbootMongoDbDemo2Application.class, args);
	}
	
	@Override
	public void run(String... args) throws Exception {

		// 최초 실행시 삭제
		mongoTemplate.remove(new Query(), "customer");
		
		// Customer 객체 생성
		Customer Bob = new Customer("Bob", "Smith");
		Customer John = new Customer("John", "Smith");
		Customer Alice = new Customer("Alice", "Smith");
		
		// mongoDB에 insert 수행. insert 메소드로 대체 가능하나 차이가 있음.
		mongoTemplate.save(Bob);
		mongoTemplate.save(John);
		mongoTemplate.save(Alice);
		
		// mongoDB에 Update 수행.
		Alice.setFirstName("Alice");
		Alice.setLastName("Chris");
		// upate 메소드로 대체 가능하나 차이가 있음.
		mongoTemplate.save(Alice); 
		
		// mongoDB에 조회 수행.
		Query query = new Query();
		Criteria criteria = Criteria.where("firstName").is("Bob");
		query.addCriteria(criteria);
		Customer temp = mongoTemplate.findOne(query, Customer.class);
		
		// mongoDB에 삭제 수행.
		mongoTemplate.remove(temp);
		
		System.out.println("Customers found with findAll():");
		System.out.println("-------------------------------");
		for (Customer customer : mongoTemplate.findAll(Customer.class)) {
			System.out.println(customer);
		}
		System.out.println();
		
		System.out.println("Customer found with findByFirstName('Alice'):");
		query = new Query(Criteria.where("firstName").is("Alice"));
		System.out.println(mongoTemplate.findOne(query, Customer.class));
		System.out.println("--------------------------------");

		System.out.println("Customers found with findByLastName('Smith'):");
		System.out.println("--------------------------------");
		query = new Query(Criteria.where("lastName").is("Smith"));
		for (Customer customer : mongoTemplate.find(query, Customer.class)) {
			System.out.println(customer);
		}
		
	}
}

 

이전글의 MongoRepository를 extends 하여 구현한 CustomerRepository 의 사용과는 조금 차이가 있지만 동일하게 동작합니다. 가장 큰 차이점은 Query 와 Criteria 객체를 이용하여 query를 만들어 세부적인 제어를 한다는 점인데요.

 

5. MongDB Compass 툴로 데이터 확인

해당 툴은 MongoDB Compass 시 기본 설치되는 툴입니다.

MongoDB Compass 툴로 접속하여 정보를 다시 확인해보겠습니다

 

 

5. Save, Insert, Update

그리고 삽입, 수정을 위해서 save 메소드를 사용했는데요.

삽입의 경우에는 insert 메소드, 수정의 경우에는 update 메소드로 대체 가능합니다.

다만 save 의 사용과 insert, update 사용에는 차이가 있기때문에 모르고 사용했을 경우 크게 낭패를 볼수도 있습니다.

save 메소드의 경우에는 _id 값이 존재하지 않으면 데이터를 삽입하지만 _id 값이 존재하는 경우 데이터가 수정됩니다.

그리고 값을 수정하는 경우에 특정 필드가 아닌 모든 데이터를 덮어쓰므로 만약 업데이트하는 필드 외에  다른 필드 값이 정의되어 있지 않은 객체를 save 메소드로 업데이트 하는 경우 데이터가 초기화 될수있다.

잘 이해가 가지 않는다면 아래의 참고 레퍼런스에서 차이점을 확인해보고 정확히 이해하고 사용할 필요가 있습니다.

 

6. 마치며 

MongoRepository도 @Query 어노테이션을 활용하여 어느정도 MongoTemplate 처럼 세부 제어는 가능하지만 개인적인 생각으로는 실무 환경에서 간단한 CRUD 만 처리하는 기능이 아닌 프로그램을 구현해야한다면 mongoTemplate를 사용하는 것이 좋지 않을까 생각됩니다. 비즈니스 환경에 따라서 고민이 필요한 부분 같습니다.

이번 코드도 Github에서 확인 가능하며, 참고한 아래의 사이트에서 더 자세히 확인 가능합니다.

 

※ 참고 

https://docs.spring.io/spring-data/mongodb/docs/current/reference/html/#reference

https://www.baeldung.com/spring-data-mongodb-tutorial

https://www.baeldung.com/queries-in-spring-data-mongodb

https://stackoverflow.com/questions/16209681/what-is-the-difference-between-save-and-insert-in-mongo-db

 

+ Recent posts