앞선 포스트(4. OAuth2 Server 확장 3 - JwtTokenStore)에서는 JWT(Json Web Token)을 통하여 Access Token과 Refresh Token을 처리하는 시스템을 구현하였습니다. 필자는 여러 사이트의 문서들을 살펴본 결과 JWT기반의 Refresh Token의 보안성이 다소 떨어진다고 판단하였습니다. (적어도 관리적인 측면에서는 일부 기능의 보완이 필요하다고 보았습니다.)
JWT기반 Token의 장단점과 보안 이슈
JWT기반의 Access Token과 Refresh Token의 시스템은 다음과 같은 장점이 있습니다.
- Token자체에 인증정보를 포함하므로 Authorization Server에 Token의 정합성을 질의하지 않으므로 부하를 분산시킨다.
- 별도의 저장소를 사용하지 않음으로 데이터베이스 연동 또는 메모리의 자원낭비를 줄일 수 있다.
하지만 다음과 같은 단점을 생각하게 되었습니다.
- 저장소를 사용하지 않는다면 이미 발급된 Token에 대한 강제 만료 처리는 할 수 없는 것인가?
- HA구성을 할 경우 Authorization Server 간의 데이터 동기화 처리는 어떻게 할 것인가?
그래서 다음과 같이 Token을 관리할 수 있도록 개발 구조를 잡았습니다.
- JWT기반 Access Token의 경우 만료일을 가급적 최소화 값으로 발급하여 관리한다.
- Refresh Token의 경우 JWT기반으로 관리하되 발급 및 검증 시 데이터베이스의 검증을 로직을 추가한다.
- 데이터베이스를 통한 Refreh Token은 사용자 식별정보(username)와 Client 식별정보(client id)를 기반으로 관리한다.
필자는 개발 업무 외에 기술지원 업무를 몇 년 동안 수행한 경험이 있습니다. 기술지원 업무 경험상 고객사의 요구는 항상 다양하고 일부 요구 중에는 프로그램에 적용하기 상당히 어려우며 적용한다고 하여도 많은 리소스를 필요로 하게 되는 경우가 많았습니다. 따라서, 다양하고 복잡한 고객사의 요구를 반영하기 위해서는 개발 시부터 요구를 반영하기 위한 설계가 중요하다는 판단을 하였습니다.(고객사의 요구가 아니더라도 관리적인 측면에서 소스의 확장성은 고려되어야 합니다.)
이러한 이슈들을 종합하여 Spring Security의 JwtTokenStore와 기존 프로젝트에서 구현하였던 OAuthTokenStoreService의 일부 코드들을 병합하기로 결정하였습니다. 대부분의 기능은 JwtTokenStore의 코드를 참고하여 작성하되 Refresh Token관련 부분에서는 데이터베이스를 통한 검증 로직과 저장 로직을 추가하여 구현할 예정입니다.
Refresh Token 관리
데이터베이스 테이블 생성 및 DAO, Model 구성
본 포스트에서는 username, client_id 필드만을 추가하는 예제를 구현하였습니다. 필요 시에 만료일(expired_date), 상태(status) 등과 같은 추가적인 필드를 추가하여 확장성을 고려하여 적절하게 응용할 수 있습니다.
테이블 생성/변경
기존의 테이블에 username, client_id를 추가하였습니다.
CREATE TABLE `oauth_refresh_token` (
`token_id` varchar(256) COLLATE utf8_bin DEFAULT NULL,
`token` varchar(4096) COLLATE utf8_bin DEFAULT NULL,
`authentication` varchar(4096) COLLATE utf8_bin DEFAULT NULL,
`username` varchar(256) COLLATE utf8_bin DEFAULT NULL,
`client_id` varchar(256) COLLATE utf8_bin DEFAULT NULL
);
Model 객체 수정
기존 속성에 username, clientId를 추가하였습니다.
public class OAuthRefreshToken {
private String tokenId;
private String token;
private String authentication;
private String username;
private String clientId;
// ...... 중간 생략 ......
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getClientId() {
return clientId;
}
public void setClientId(String clientId) {
this.clientId = clientId;
}
}
Mapper SQL 수정
<select id="findRefreshTokenByTokenId" resultType="kr.ejsoft.oauth2.server.model.OAuthRefreshToken">
<![CDATA[
SELECT
*
FROM
oauth_refresh_token
WHERE
token_id = #{tokenId}
]]>
</select>
<insert id="saveRefreshToken" keyProperty="token_id">
insert into oauth_refresh_token
(token_id, token, authentication, username, client_id)
values
(#{tokenId}, #{token}, #{authentication}, #{username}, #{clientId})
</insert>
<delete id="deleteRefreshToken">
delete from oauth_refresh_token where token_id = #{tokenId}
</delete>
DAO 객체 수정
@Repository("oauthDAO")
public class OAuthDAO{
@Autowired
private SqlSessionTemplate sqlSession;
// ...... 중간 생략 ......
public OAuthRefreshToken findRefreshTokenByTokenId(String tokenId) {
return sqlSession.selectOne("oauth.findRefreshTokenByTokenId", tokenId);
}
public int saveRefreshToken(OAuthRefreshToken refreshToken) {
return sqlSession.insert("oauth.saveRefreshToken", refreshToken);
}
public int deleteRefreshToken(OAuthRefreshToken refreshToken) {
return sqlSession.delete("oauth.deleteRefreshToken", refreshToken);
}
}
JWT Token Store, AuthorizationServerConfig 수정
JWT Token Store 수정
package kr.ejsoft.oauth2.server.service;
import java.io.UnsupportedEncodingException;
import java.math.BigInteger;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.oauth2.common.OAuth2RefreshToken;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;
import kr.ejsoft.oauth2.server.dao.OAuthDAO;
import kr.ejsoft.oauth2.server.model.OAuthRefreshToken;
public class OAuthJwtTokenStore extends JwtTokenStore {
@Autowired
@Qualifier("oauthDAO")
private OAuthDAO dao;
// 생성자 함수를 재정의합니다.
public OAuthJwtTokenStore(JwtAccessTokenConverter jwtTokenEnhancer) {
super(jwtTokenEnhancer);
}
// JwtTokenStore에는 저장로직이 없습니다. 데이터베이스에 저장하는 로직을 아래와 같이 구성합니다.
@Override
public void storeRefreshToken(OAuth2RefreshToken refreshToken, OAuth2Authentication authentication) {
OAuthRefreshToken crt = new OAuthRefreshToken();
crt.setTokenId(extractTokenKey(refreshToken.getValue()));
crt.setTokenObject(refreshToken);
crt.setAuthenticationObject(authentication);
crt.setUsername(authentication.isClientOnly() ? null : authentication.getName());
crt.setClientId(authentication.getOAuth2Request().getClientId());
dao.saveRefreshToken(crt);
}
// Jwt에서 Refresh Token을 읽어오고 값이 존재한다면 데이터베이스에서 Refresh Token의 정보를 읽어옵니다.
@Override
public OAuth2RefreshToken readRefreshToken(String tokenValue) {
OAuth2RefreshToken token = super.readRefreshToken(tokenValue);
if(token != null) {
OAuthRefreshToken refreshToken = dao.findRefreshTokenByTokenId(extractTokenKey(tokenValue));
return refreshToken != null ? refreshToken.getTokenObject() : null;
}
return token;
}
// Jwt에서 인증정보를 살펴보고 값이 존재한다면 데이터베이스에서도 읽어옵니다.
@Override
public OAuth2Authentication readAuthenticationForRefreshToken(OAuth2RefreshToken refreshToken) {
OAuth2Authentication oauth = super.readAuthenticationForRefreshToken(refreshToken);
if(oauth != null) {
OAuthRefreshToken rtk = dao.findRefreshTokenByTokenId(extractTokenKey(refreshToken.getValue()));
return rtk != null ? rtk.getAuthenticationObject() : null;
}
return oauth;
}
// Refresh Token을 삭제합니다.
@Override
public void removeRefreshToken(OAuth2RefreshToken refreshToken) {
super.removeRefreshToken(refreshToken);
OAuthRefreshToken rtk = dao.findRefreshTokenByTokenId(extractTokenKey(refreshToken.getValue()));
if (rtk != null) {
dao.deleteRefreshToken(rtk);
}
}
private String extractTokenKey(String value) {
if (value == null) {
return null;
} else {
MessageDigest digest;
try {
digest = MessageDigest.getInstance("MD5");
} catch (NoSuchAlgorithmException var5) {
throw new IllegalStateException("MD5 algorithm not available. Fatal (should be in the JDK).");
}
try {
byte[] e = digest.digest(value.getBytes("UTF-8"));
return String.format("%032x", new Object[] { new BigInteger(1, e) });
} catch (UnsupportedEncodingException var4) {
throw new IllegalStateException("UTF-8 encoding not available. Fatal (should be in the JDK).");
}
}
}
}
AuthorizationServerConfig 수정
Spring Security OAuth2의 JwtTokenStore에서 OAuthJwtTokenStore로 인스턴스 생성으로 변경합니다.
@Bean
public TokenStore tokenStore() {
// JwtTokenStore tokenStore = new JwtTokenStore(accessTokenConverter());
JwtTokenStore tokenStore = new OAuthJwtTokenStore(accessTokenConverter());
tokenStore.setApprovalStore(approvalStore);
return tokenStore;
}
Refresh Token 관리 테스트
Refresh Token 테스트
POST방식으로 토큰을 요청합니다.
요청 주소 : http://localhost:9090/oauth/token
요청 헤더 :
Content-Type=application/x-www-form-urlencoded
Authorization: Basic Y2xpZW50OnNlY3JldA==
헤더의 Authorization 값은 makeAuthorizationRequestHeader() 함수에서 출력되는 로그를 복사하여 사용합니다.
요청 내용
grant_type=refresh_token&scope=read_profile&refresh_token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJ1c2VyIiwic2NvcGUiOlsicmVhZF9wcm9maWxlIl0sImF0aSI6ImIxODljOTNiLWJkMzMtNGUzMC1hOTIzLTBlY2UzZTNjMjNjMiIsImV4cCI6MTU3Nzg2MDEzNSwiYXV0aG9yaXRpZXMiOlsidXNlciwgUk9MRV9VU0VSIl0sImp0aSI6IjM5YWEzNzRhLTVjZDAtNDAyZi1hZDNjLTc1OWFlNTI3MzJlMiIsImNsaWVudF9pZCI6ImNsaWVudCJ9.xeAcRCvamJlkzhclPfY2ZxFXALiIDu8AHnXYYp8Qn5MrV0Wkz3pZPrcZAOp12p6NfJJPO_jLJzjaF82ZFlOlYtdJqIqYPukI_Bl1qV79PLDF75710dI_YeoGfSIzW3A1QjkWwhQ6qS9UOyRPALTUwkM6HZG-k9D_HH6t0mKji7xSXtgMU3Efp3vCHR4fu4ZyQJazU2ymzRLwQwHnomU79zSrGaJShkOjuBbMjXsf0UtumSp1_FS5amGetLz4LP8K2eCjdUh3PrRbNyFk3Lb6NJPkrne8QCiENTJ6S2wyXyOxcyVUEi_3K3YF3V5Gg-fTBOcAdSBmuqBFTTymJUTVsA&
요청 본문
POST /oauth/token HTTP/1.1
Authorization: Basic Y2xpZW50OnNlY3JldA==
Content-Type: application/x-www-form-urlencoded
User-Agent: http4e/5.0.12
Host: localhost:9090
Content-Length: 715
grant_type=refresh_token&scope=read_profile&refresh_token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJ1c2VyIiwic2NvcGUiOlsicmVhZF9wcm9maWxlIl0sImF0aSI6ImIxODljOTNiLWJkMzMtNGUzMC1hOTIzLTBlY2UzZTNjMjNjMiIsImV4cCI6MTU3Nzg2MDEzNSwiYXV0aG9yaXRpZXMiOlsidXNlciwgUk9MRV9VU0VSIl0sImp0aSI6IjM5YWEzNzRhLTVjZDAtNDAyZi1hZDNjLTc1OWFlNTI3MzJlMiIsImNsaWVudF9pZCI6ImNsaWVudCJ9.xeAcRCvamJlkzhclPfY2ZxFXALiIDu8AHnXYYp8Qn5MrV0Wkz3pZPrcZAOp12p6NfJJPO_jLJzjaF82ZFlOlYtdJqIqYPukI_Bl1qV79PLDF75710dI_YeoGfSIzW3A1QjkWwhQ6qS9UOyRPALTUwkM6HZG-k9D_HH6t0mKji7xSXtgMU3Efp3vCHR4fu4ZyQJazU2ymzRLwQwHnomU79zSrGaJShkOjuBbMjXsf0UtumSp1_FS5amGetLz4LP8K2eCjdUh3PrRbNyFk3Lb6NJPkrne8QCiENTJ6S2wyXyOxcyVUEi_3K3YF3V5Gg-fTBOcAdSBmuqBFTTymJUTVsA&
응답 본문
HTTP/1.1 200
Pragma: no-cache
Cache-Control: no-store
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
X-Frame-Options: DENY
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Mon, 02 Dec 2019 06:30:39 GMT
{"access_token":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NzUyNzE4MzksInVzZXJfbmFtZSI6InVzZXIiLCJhdXRob3JpdGllcyI6WyJ1c2VyLCBST0xFX1VTRVIiXSwianRpIjoiOGQ5NDJlYjktNTgwMC00ZGNhLWFhMWItMTljMTM4YTkwZTZiIiwiY2xpZW50X2lkIjoiY2xpZW50Iiwic2NvcGUiOlsicmVhZF9wcm9maWxlIl19.l9gtPipXmoc1lr_C5_LkwWePb63I7kqAppUXP97vmlfH8xomZ1K60Bbb2xvkUoVyubV1alG2tbkNNZ_3aX2UxLHnlUEVe6ZGxrRb6ncgPOPjKyzwhTuIwDs29XU4o18StLNUfR-9b74XT5PT8tSf22EvV5Fp9-hKFJBBDvj5wnUH3jUotAZppx2dfwLVHYLvjegqfGfIZrg2bKqHeWUugSpoCj1Jlrrd-qnFPUMd-ACDQjU3b2_xgGvxDjB8rwnxlgc9iLdPhr2xH5MBQUw-iHUdxmMpLJNm2oQNiJH4nQs8jXqgDl_qzJRw3sTVyVdw0TLsq2BlGOgoD2TLQt19gg","token_type":"bearer","refresh_token":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJ1c2VyIiwic2NvcGUiOlsicmVhZF9wcm9maWxlIl0sImF0aSI6IjhkOTQyZWI5LTU4MDAtNGRjYS1hYTFiLTE5YzEzOGE5MGU2YiIsImV4cCI6MTU3Nzg2MDEzNSwiYXV0aG9yaXRpZXMiOlsidXNlciwgUk9MRV9VU0VSIl0sImp0aSI6IjM5YWEzNzRhLTVjZDAtNDAyZi1hZDNjLTc1OWFlNTI3MzJlMiIsImNsaWVudF9pZCI6ImNsaWVudCJ9.O4FqjMkiPiutHNYdqYXjJx6Tx9hrwW0dVTafYDLYjFzRp_lwU8JYLUZ0ChRu5PTe3riV-CNTWCvSl3AzkxnLt5hlp4OqK5vmmCmSc8oH541sMSAW3zBGzmOJPGMLpMRLOmNYTB7hu3sAaBgqG9hVSpxNC57WjnRMTCuCWkLNGqpjUDiSGyAu-WVYiuROjuwIQ1lfzp7Ji2zqSO84mSgXmbHKVXjDwbHsActJvZkKjiUdeNKjO5GTCPmLZs5sQMhMhF4PvfpL5wjpUIpBdT_a_2sYmFMrxX6zZwZqj7SpUcx6-rDU5bsQxGQEPDhy9aJShThI0Z_44inJvipNESdrGA","expires_in":3599,"scope":"read_profile","jti":"8d942eb9-5800-4dca-aa1b-19c138a90e6b"}
접근 토큰을 Authorization에 요청하면 응답으로 접근 토큰이 재생성되어 반환됩니다.
데이터베이스 Refresh Token 삭제 및 테스트
데이터베이스의 테이블에서 해당 Refresh Token을 삭제한 후 Refresh Token의 갱신 요청은 동일하게 호출합니다.
응답 본문
HTTP/1.1 400
Pragma: no-cache
Cache-Control: no-store
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
X-Frame-Options: DENY
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Mon, 02 Dec 2019 06:32:05 GMT
Connection: close
{"error":"invalid_grant","error_description":"Invalid refresh token: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJ1c2VyIiwic2NvcGUiOlsicmVhZF9wcm9maWxlIl0sImF0aSI6ImIxODljOTNiLWJkMzMtNGUzMC1hOTIzLTBlY2UzZTNjMjNjMiIsImV4cCI6MTU3Nzg2MDEzNSwiYXV0aG9yaXRpZXMiOlsidXNlciwgUk9MRV9VU0VSIl0sImp0aSI6IjM5YWEzNzRhLTVjZDAtNDAyZi1hZDNjLTc1OWFlNTI3MzJlMiIsImNsaWVudF9pZCI6ImNsaWVudCJ9.xeAcRCvamJlkzhclPfY2ZxFXALiIDu8AHnXYYp8Qn5MrV0Wkz3pZPrcZAOp12p6NfJJPO_jLJzjaF82ZFlOlYtdJqIqYPukI_Bl1qV79PLDF75710dI_YeoGfSIzW3A1QjkWwhQ6qS9UOyRPALTUwkM6HZG-k9D_HH6t0mKji7xSXtgMU3Efp3vCHR4fu4ZyQJazU2ymzRLwQwHnomU79zSrGaJShkOjuBbMjXsf0UtumSp1_FS5amGetLz4LP8K2eCjdUh3PrRbNyFk3Lb6NJPkrne8QCiENTJ6S2wyXyOxcyVUEi_3K3YF3V5Gg-fTBOcAdSBmuqBFTTymJUTVsA"}
Access Token 신규 발급 시에 invalid_grant 오류를 발생합니다.
글을 마치며
본 글에서 Refresh Token을 데이터베이스에 저장할 때 username, clientId만을 추가하였습니다. 필요시 Refresh Token의 만료 시각, 상태 필드 등을 추가할 수 있습니다. 위에서 구현했던 TokenStore의 readRefreshToken() 함수를 적절하게 수정하여 만료일에 대한 검증 로직을 추가하면 됩니다.
OAuth2의 관리 시에 예상되는 몇 가지 관리 시나리오에 대해서 생각해 봅니다.
- 고객사의 모든 토큰을 만료시키고 싶습니다.
: JWT 구조상 Access Token은 만료 전까지는 강제 만료 처리할 수 없으며 Refresh Token은 만료처리 가능합니다. - 특정 고객사의 사용자를 만료 처리하고 싶습니다.
: JWT 구조상 Access Token은 만료 전까지는 강제 만료 처리할 수 없으며 Refresh Token은 만료처리 가능합니다. - 특정 고객사의 사용자가 불법 사용자로 의심됩니다. 모니터링 가능합니까?
Refresh Token 데이터베이스 필드에 상태 코드 필드가 있다면 가능합니다. 상태는 업무 로직에 따라 적절하게 구현(정상, 잠김, 만료, 감시대상)하면 됩니다. Refresh Token을 통해 Access Token을 재발급받을 때부터 상태 필드의 값 확인 후 모니터링이 가능합니다.
참고사이트
- Refresh Token과 Sliding Sessions를 활용한 JWT의 보안 전략
https://blog.ull.im/engineering/2019/02/07/jwt-strategy.html - JWT를 구현하면서 마주치게 되는 고민들
https://swalloow.github.io/implement-jwt - JWT Access and Refresh Token with Vapor 3
https://medium.com/quick-code/jwt-access-and-refresh-token-with-vapor-3-85a0aee5291b
소스코드
소스코드는 여기에서 다운로드 가능합니다.
'Development > Spring Framework, Security, OAuth2' 카테고리의 다른 글
Spring Boot with OAuth2 - 9. OAuth2 Server 로그인화면 수정 (0) | 2019.12.17 |
---|---|
Spring Boot with OAuth2 - 8. OAuth2 Server - Client, User Cache (0) | 2019.12.03 |
Spring Boot with OAuth2 - 6. OAuth2 Server 접근/권한 제어 (0) | 2019.11.27 |
Spring Boot with OAuth2 - 5. OAuth2 Resource Server 분리 (0) | 2019.11.27 |
Spring Boot with OAuth2 - 4. OAuth2 Server 확장 3 - JwtTokenStore (0) | 2019.11.26 |