단점 테스트 코드를 통해 작성된 문서가 아니기 때문에 신뢰도가 상대적으로 떨어진다. 프로덕션 코드에 작성 코드가 포함된다. Map의 경우 직접적으로 설명을 작성하기가 어렵다. Swagger 어노테이션으로 대부분 작성하므로 학습 필요.
Spring Rest Docs와 Swagger은 장단점은 극명한 것 같습니다.
하지만 어떤 것이 좋을지는 환경과 비즈니스 요구사항에 맞춰서 대응해야 한다고 봅니다.
레거시 프로젝트가 있고, 많은 양의 API가 있는데 빠른 시간 내에 API 문서가 필요하다면? Spring Rest Docs를 적용하게 되면 전부 테스트 코드를 작성해줘야 하고 따라서 시간이 매우 소모되는 작업이므로 적합하지 않을 수 있습니다. 물론 둘 다 모르는 것에 학습은 해야 되지만 개인적으로는 Swagger가 훨씬 작성하기는 쉬워 보였습니다.
처음 시작하는 프로젝트이고 신뢰성 있는 API 문서가 필요하다면? Spring Rest Docs를 적용하게 되면 전부 테스트 코드 작성해야 하지만 검증된 신뢰성 있는 API 문서를 얻을 수 있습니다.
이외에도 프로덕션 코드에 코드 추가 여부나 작업자의 학습도, 역량에 따라서 달라질 수 있다고 봅니다.
하지만 장기적으로 봤을 때는 Spring Rest Docs를 적용하는 게 TDD 개발을 하는 곳이라면 낫지 않을까 싶습니다.
setup 메서드 mockMvc 인스턴스를 사용하여 구성. 스니펫은 기본적으로 6개 curl, http-request, http-resonse, httpie-request, request-body, resonse-body를 생성. /target/generated-snippets/~ 에서 생성된 스니펫 확인 가능합니다.
getUserInfoById 메서드 DemoController의 /demo/user/{id} API의 테스트 코드입니다. pathParameter는 @Pathvariable로 받는 id 값의 테스트를 위한 설정인데 사용할 경우 mockMvcBuilder를 사용하면 컴파일 타임에서 괜찮지만 mvc install 과정에서 Exception이 발생합니다.
urlTemplate not found. If you are using MockMvc did you use RestDocumentationRequestBuilders to build the request?
메시지 권고대로 mockMvcBuilder 대신 RestDocumentationRequestBuilders를 사용하면 에러가 발생하지 않습니다.
registUserInfo 메서드 DemoController의 /demo/user API의 테스트 코드입니다.
기타 @ExtendWith는 Junit5 설정이다. spring rest docs 공식 레퍼런스에도 잘 설명되어있습니다. Junit4와 설정 차이가 좀 있으니 서두에서 이야기한 Junit4 Demo와 비교해보기를 바랍니다.
6. api-docs.adoc
/src/main/asciidoc 경로에 파일을 생성합니다.
해당 파일을 통해 html 파일을 생성합니다. 여기서는 api-docs.html 파일이 /target/generated-docs 경로에 생성됩니다.
※ 개발 환경 : STS 4.8.1, JDK 1.8, Spring Boot 2.1.6, Mybatis 2.1, MySQL Community 5.6, Maven 4.0.0
1. Resource Server 구현을 위한 Spring Boot 프로젝트 생성 및 의존성 설정
프로젝트, 패키지 명은 적절하게 선택하여 Spring Stater Project 생성합니다.
의존성 설정은 아래의 pom.xml 파일을 참고해주세요.
2.ResourceServer프로젝트 구조
아래 이미지와 같은 구조를 가지게 됩니다.
- com.codjecd.common : Custom Exception 등 Custom 패키지, MessageProperties 등 공통 컴포넌트 패키지
- com.codejcd.config : 각종 설정 파일들의 패키지
- com.codejcd.dao : DB 액세스를 파일들을 위한 패키지
- com.codejcd.service : 서비스 레이어 패키지
- com.codejcd.controller : 컨트롤러 레이어 패키지
- com.codejcd.util : 유틸 파일 패키지
3. Message Source 설정
Exception 등 서버에서 처리할 메시지를 정의하고 맵핑하는 설정. /src/main/resources/messages/ message.*.properties 파일들과 맵핑됩니다.
Locale을 KOREA로 설정했기떄문에 message.ko.properties 가 기본 메시지 프로퍼티로 사용됩니다.
3.1 Message Source Configuration
package com.codejcd.config;
import java.util.Locale;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ReloadableResourceBundleMessageSource;
/**
* 메시지 설정
*/
@Configuration
public class MessageSourceConfiguration {
@Bean
public MessageSource messageSource() {
ReloadableResourceBundleMessageSource source = new ReloadableResourceBundleMessageSource();
source.setBasename("classpath:/messages/message");
source.setDefaultEncoding("UTF-8");
source.setCacheSeconds(10);
source.setUseCodeAsDefaultMessage(true);
Locale.setDefault(Locale.KOREA);
return source;
}
}
3.2 Message Source Properties
src/main/resources/messages 폴더를 생성하고 message_ko.properties 파일을 생성합니다.
code와 message 값을 정의합니다. 프로젝트의 크기에 따라 code 의 범위는 적절하게 조절하세요. 여기서는 데모 앱이므로 범위가 작습니다.
error.code.common.success=000
error.message.common.success=Process Success
error.code.common.occured.exception=999
error.message.common.occured.exception=Process Fail
error.code.userId.invalid=000
error.message.userId.invalid=User ID is invalid
error.code.userPassword.invalid=001
error.message.userPassword.invalid=User Password is invalid
error.code.token.expiration.invalid=010
error.message.token.expiration.invalid=token is invalid
error.code.token.expiration=011
error.message.token.expiration=token is expired
error.code.token.issuer.invalid=012
error.message.token.issuer.invalid=issuer is invalid
error.code.token.audience.invalid=013
error.message.token.audience.invalid=audience is invalid
error.code.token.id.invalid=014
error.message.token.id.invalid=token ID is invalid
error.code.authorization.invalid=020
error.message.authorization.invalid=authorization is invalid
3.3 Message Properties
MessageSoruceAware를 상속받는 클래스.
3.2에 정의한 properties 파일에서 key, vaule 관계로 정의한 코드, 메시지 값을 가져와
package com.codejcd.service;
import java.util.Date;
import javax.xml.bind.DatatypeConverter;
import org.apache.tomcat.util.codec.binary.Base64;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.PropertySource;
import org.springframework.stereotype.Service;
import com.codejcd.common.CustomException;
import com.codejcd.common.MessageProperties;
import com.codejcd.util.DateUtil;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
@Service
@PropertySource("classpath:jwt.properties")
public class TokenServiceImpl implements TokenService {
@Value("${jwt.secretKey}")
private String SECRET_KEY;
@Value("${jwt.issuer}")
private String ISSUER;
@Value("${jwt.audience}")
private String AUDIENCE;
@Value("${jwt.subject}")
private String SUBJECT;
@Override
public boolean checkAccessToken(String accessToken) throws Exception {
if (null == accessToken || "".equals(accessToken)) {
throw new CustomException(MessageProperties.prop("error.code.token.invalid")
, MessageProperties.prop("error.message.token.invalid"));
}
String[] bearerToken = accessToken.split("Bearer ");
if (null == bearerToken[1]) {
throw new CustomException(MessageProperties.prop("error.code.token.not.bearer")
, MessageProperties.prop("error.message.token.not.bearer"));
}
boolean result = false;
byte[] secretKeyBytes = DatatypeConverter.parseBase64Binary(Base64.encodeBase64String(SECRET_KEY.getBytes()));
Claims claims = null;
claims = Jwts.parser()
.setSigningKey(secretKeyBytes)
.parseClaimsJws(bearerToken[1]).getBody();
Date expitration;
Date now = DateUtil.getNowDate();
String issuer;
String audience;
if (null == claims.getExpiration()) {
throw new CustomException(MessageProperties.prop("error.code.token.expiration.invalid")
, MessageProperties.prop("error.message.token.expiration.invalid"));
}
expitration = claims.getExpiration();
if (DateUtil.isBefore(expitration, now)) {
throw new CustomException(MessageProperties.prop("error.code.token.expiration")
, MessageProperties.prop("error.message.token.expiration"));
}
if (null == claims.getIssuer() || "".equals(claims.getIssuer())) {
throw new CustomException(MessageProperties.prop("error.code.token.issuer.invalid")
, MessageProperties.prop("error.message.token.issuer.invalid"));
}
issuer = claims.getIssuer();
if (!ISSUER.equals(issuer)) {
throw new CustomException(MessageProperties.prop("error.code.token.issuer.invalid")
, MessageProperties.prop("error.message.token.issuer.invalid"));
}
if (null == claims.getAudience() || "".equals(claims.getAudience())) {
throw new CustomException(MessageProperties.prop("error.code.token.audience.invalid")
, MessageProperties.prop("error.message.token.audience.invalid"));
}
audience = claims.getAudience();
if (!AUDIENCE.equals(audience)) {
throw new CustomException(MessageProperties.prop("error.code.token.audience.invalid")
, MessageProperties.prop("error.message.token.audience.invalid"));
}
if (null == claims.getId() || "".equals(claims.getId())) {
throw new CustomException(MessageProperties.prop("error.code.token.id.invalid")
, MessageProperties.prop("error.message.token.id.invalid"));
}
result = true;
//claims.getIssuedAt();
//claims.getSubject();
return result;
}
}
checkAccessToken 메서드 Bearere Token에 대한 null, type 등 기본적인 유효성 체크 이후에 jwt.properties에 미리 정의해둔 secret key를 통해 디코딩하여 Claims 객체를 만듭니다. Claims에 정의된 메서드를 통해 속성에 접근하여 토큰의 유효성을 체크하고 결과를 리턴합니다.
11. 테스트
테스트 툴로는 POSTMAN을 사용했습니다. 이전 글에 미리 만든 Authorization 서버를 스타트하고 Resource 서버도 스타트합니다.
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
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;
}
/client/regist ClientService의 registClient를 호출하여 Request로 전달받은 Client 데이터를 매개변수로 넘겨줍니다. Exception이 없으면 정상 응답 코드가 세팅되고, Exception 발생 시 try~catch~finally 문에 의해 처리됩니다. 이번 편에서는 별도의 테스트 데이터 SQL이 없으므로 해당 API를 호출하여 client 값을 생성합니다.
/oauth2/tokens ClientService, userService를 호출하여 각각 Request로 전달받은 Client 정보와 User 정보를 검증한다. 이후 tokenService의 getTokens 메서드를 user 정보를 매개 변수로 하여 호출하여 액세스 토큰을 발급받아 리턴합니다.
/oauth2/accessToken/refresh Client 정보와 리프레시 토큰을 Request로 전달받아 검증 후 유효하면 Access Token을 리턴합니다.
/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 처리한 메시지를 확인 가능합니다.
이번에는 잘못 등록한 값을 제대로 세팅하고 호출합니다.
정상적으로 토큰이 발급된 것을 확인 가능합니다.
블로그에서는 간단하게 테스트하는 방법을 보여드렸고,
실제로는 여러 가지 상황을 가정해서 테스트 케이스를 만들고 더 상세하게 테스트해봐야 합니다.
17. 마치며 이번 글에서는 Spring Security Oauth 없이
Basic Auth에 대한 처리와 Access, Refresh Token을 생성하는 기능을 구현해봤습니다. 다음 글에서는 Resource Server를 구현해보고 이상 이번 시리즈를 마치겠습니다.