이전 블로그 게시물에서는 간단한 OAuth2 인증 구성 방법. 하지만 저희 구현에는 인메모리 토큰 저장소를 사용하고 있다는 큰 결함이 있습니다.
인메모리 토큰 저장소는 노드 간에 쉽게 공유할 수 없고 서버가 다시 시작될 경우 저장소의 모든 액세스 토큰을 잃게 되므로 개발 중이거나 애플리케이션에 단일 서버가 있는 경우에만 사용해야 합니다.
스프링 보안 인증 2 에는 이미 JDBC 및 JWT에 대한 기본 지원 기능이 있습니다. 그러나 토큰을 다른 곳에 저장해야 하는 경우에는 자체 스프링 보안 토큰 저장소를 만들어야 합니다. 안타깝게도 이러한 작업을 구현하는 것은 사소한 작업이 아니며 다음 레시피를 통해 몇 시간의 작업을 절약할 수 있기를 바랍니다.
액세스 토큰과 새로 고침 토큰을 저장하는 두 개의 엔티티와 각각의 리포지토리를 만드는 것부터 시작하겠습니다:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
import lombok.Data; import org.springframework.data.annotation.Id; import org.springframework.data.couchbase.core.mapping.Document; import org.springframework.security.oauth2.common.OAuth2AccessToken; import org.springframework.security.oauth2.provider.OAuth2Authentication; @Document @Data public class CouchbaseAccessToken { @Id private String id; private String tokenId; private OAuth2AccessToken token; private String authenticationId; private String username; private String clientId; private String authentication; private String refreshToken; public OAuth2Authentication getAuthentication() { return SerializableObjectConverter.deserialize(authentication); } public void setAuthentication(OAuth2Authentication authentication) { this.authentication = SerializableObjectConverter.serialize(authentication); } } |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
@N1qlPrimaryIndexed @ViewIndexed(designDoc = "couchbaseAccessToken") public interface CouchbaseAccessTokenRepository extends CouchbasePagingAndSortingRepository<CouchbaseAccessToken, String> { List<CouchbaseAccessToken> findByClientId(String clientId); List<CouchbaseAccessToken> findByClientIdAndUsername(String clientId, String username); Optional<CouchbaseAccessToken> findByTokenId(String tokenId); Optional<CouchbaseAccessToken> findByRefreshToken(String refreshToken); Optional<CouchbaseAccessToken> findByAuthenticationId(String authenticationId); } |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
import lombok.Data; import org.springframework.data.annotation.Id; import org.springframework.data.couchbase.core.mapping.Document; import org.springframework.security.oauth2.common.OAuth2RefreshToken; import org.springframework.security.oauth2.provider.OAuth2Authentication; @Document @Data public class CouchbaseRefreshToken { @Id private String id; private String tokenId; private OAuth2RefreshToken token; private String authentication; public OAuth2Authentication getAuthentication() { return SerializableObjectConverter.deserialize(authentication); } public void setAuthentication(OAuth2Authentication authentication) { this.authentication = SerializableObjectConverter.serialize(authentication); } } |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
import org.springframework.data.couchbase.core.query.N1qlPrimaryIndexed; import org.springframework.data.couchbase.core.query.ViewIndexed; import org.springframework.data.couchbase.repository.CouchbasePagingAndSortingRepository; import java.util.List; import java.util.Optional; @N1qlPrimaryIndexed @ViewIndexed(designDoc = "couchbaseAccessToken") public interface CouchbaseAccessTokenRepository extends CouchbasePagingAndSortingRepository<CouchbaseAccessToken, String> { List<CouchbaseAccessToken> findByClientId(String clientId); List<CouchbaseAccessToken> findByClientIdAndUsername(String clientId, String username); Optional<CouchbaseAccessToken> findByTokenId(String tokenId); Optional<CouchbaseAccessToken> findByRefreshToken(String refreshToken); Optional<CouchbaseAccessToken> findByAuthenticationId(String authenticationId); } |
참고 OAuth2인증 는 인터페이스이므로 객체를 직렬화하여 데이터베이스에 저장하는 것 외에 다른 옵션이 없습니다. 다음은 직렬화/역직렬화를 담당하는 클래스입니다:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
public class SerializableObjectConverter { public static String serialize(OAuth2Authentication object) { try { byte[] bytes = SerializationUtils.serialize(object); return Base64.encodeBase64String(bytes); } catch(Exception e) { e.printStackTrace(); throw e; } } public static OAuth2Authentication deserialize(String encodedObject) { try { byte[] bytes = Base64.decodeBase64(encodedObject); return (OAuth2Authentication) SerializationUtils.deserialize(bytes); } catch(Exception e) { e.printStackTrace(); throw e; } } |
이제 드디어 사용자 정의 스프링 오아시스2 토큰 저장소입니다. 이를 위해 필요한 것은 토큰 저장소의 긴 메서드 목록인 org.springframework.security.oauth2.provider.token.TokenStore:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 |
import org.springframework.security.oauth2.common.OAuth2AccessToken; import org.springframework.security.oauth2.common.OAuth2RefreshToken; import org.springframework.security.oauth2.provider.OAuth2Authentication; import org.springframework.security.oauth2.provider.token.AuthenticationKeyGenerator; import org.springframework.security.oauth2.provider.token.DefaultAuthenticationKeyGenerator; import org.springframework.security.oauth2.provider.token.TokenStore; import java.io.UnsupportedEncodingException; import java.math.BigInteger; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.*; public class CouchbaseTokenStore implements TokenStore { private CouchbaseAccessTokenRepository cbAccessTokenRepository; private CouchbaseRefreshTokenRepository cbRefreshTokenRepository; public CouchbaseTokenStore(CouchbaseAccessTokenRepository cbAccessTokenRepository, CouchbaseRefreshTokenRepository cbRefreshTokenRepository){ this.cbAccessTokenRepository = cbAccessTokenRepository; this.cbRefreshTokenRepository = cbRefreshTokenRepository; } private AuthenticationKeyGenerator authenticationKeyGenerator = new DefaultAuthenticationKeyGenerator(); @Override public OAuth2Authentication readAuthentication(OAuth2AccessToken accessToken) { return readAuthentication(accessToken.getValue()); } @Override public OAuth2Authentication readAuthentication(String token) { Optional<CouchbaseAccessToken> accessToken = cbAccessTokenRepository.findByTokenId(extractTokenKey(token)); if (accessToken.isPresent()) { return accessToken.get().getAuthentication(); } return null; } @Override public void storeAccessToken(OAuth2AccessToken accessToken, OAuth2Authentication authentication) { String refreshToken = null; if (accessToken.getRefreshToken() != null) { refreshToken = accessToken.getRefreshToken().getValue(); } if (readAccessToken(accessToken.getValue()) != null) { this.removeAccessToken(accessToken); } CouchbaseAccessToken cat = new CouchbaseAccessToken(); cat.setId(UUID.randomUUID().toString()+UUID.randomUUID().toString()); cat.setTokenId(extractTokenKey(accessToken.getValue())); cat.setToken(accessToken); cat.setAuthenticationId(authenticationKeyGenerator.extractKey(authentication)); cat.setUsername(authentication.isClientOnly() ? null : authentication.getName()); cat.setClientId(authentication.getOAuth2Request().getClientId()); cat.setAuthentication(authentication); cat.setRefreshToken(extractTokenKey(refreshToken)); cbAccessTokenRepository.save(cat); } @Override public OAuth2AccessToken readAccessToken(String tokenValue) { Optional<CouchbaseAccessToken> accessToken = cbAccessTokenRepository.findByTokenId(extractTokenKey(tokenValue)); if (accessToken.isPresent()) { return accessToken.get().getToken(); } return null; } @Override public void removeAccessToken(OAuth2AccessToken oAuth2AccessToken) { Optional<CouchbaseAccessToken> accessToken = cbAccessTokenRepository.findByTokenId(extractTokenKey(oAuth2AccessToken.getValue())); if (accessToken.isPresent()) { cbAccessTokenRepository.delete(accessToken.get()); } } @Override public void storeRefreshToken(OAuth2RefreshToken refreshToken, OAuth2Authentication authentication) { CouchbaseRefreshToken crt = new CouchbaseRefreshToken(); crt.setId(UUID.randomUUID().toString()+UUID.randomUUID().toString()); crt.setTokenId(extractTokenKey(refreshToken.getValue())); crt.setToken(refreshToken); crt.setAuthentication(authentication); cbRefreshTokenRepository.save(crt); } @Override public OAuth2RefreshToken readRefreshToken(String tokenValue) { Optional<CouchbaseRefreshToken> refreshToken = cbRefreshTokenRepository.findByTokenId(extractTokenKey(tokenValue)); return refreshToken.isPresent()? refreshToken.get().getToken() :null; } @Override public OAuth2Authentication readAuthenticationForRefreshToken(OAuth2RefreshToken refreshToken) { Optional<CouchbaseRefreshToken> rtk = cbRefreshTokenRepository.findByTokenId(extractTokenKey(refreshToken.getValue())); return rtk.isPresent()? rtk.get().getAuthentication() :null; } @Override public void removeRefreshToken(OAuth2RefreshToken refreshToken) { Optional<CouchbaseRefreshToken> rtk = cbRefreshTokenRepository.findByTokenId(extractTokenKey(refreshToken.getValue())); if (rtk.isPresent()) { cbRefreshTokenRepository.delete(rtk.get()); } } @Override public void removeAccessTokenUsingRefreshToken(OAuth2RefreshToken refreshToken) { Optional<CouchbaseAccessToken> token = cbAccessTokenRepository.findByRefreshToken(extractTokenKey(refreshToken.getValue())); if(token.isPresent()){ cbAccessTokenRepository.delete(token.get()); } } @Override public OAuth2AccessToken getAccessToken(OAuth2Authentication authentication) { OAuth2AccessToken accessToken = null; String authenticationId = authenticationKeyGenerator.extractKey(authentication); Optional<CouchbaseAccessToken> token = cbAccessTokenRepository.findByAuthenticationId(authenticationId); if(token.isPresent()) { accessToken = token.get().getToken(); if(accessToken != null && !authenticationId.equals(this.authenticationKeyGenerator.extractKey(this.readAuthentication(accessToken)))) { this.removeAccessToken(accessToken); this.storeAccessToken(accessToken, authentication); } } return accessToken; } @Override public Collection<OAuth2AccessToken> findTokensByClientIdAndUserName(String clientId, String userName) { Collection<OAuth2AccessToken> tokens = new ArrayList<OAuth2AccessToken>(); List<CouchbaseAccessToken> result = cbAccessTokenRepository.findByClientIdAndUsername(clientId, userName); result.forEach(e-> tokens.add(e.getToken())); return tokens; } @Override public Collection<OAuth2AccessToken> findTokensByClientId(String clientId) { Collection<OAuth2AccessToken> tokens = new ArrayList<OAuth2AccessToken>(); List<CouchbaseAccessToken> result = cbAccessTokenRepository.findByClientId(clientId); result.forEach(e-> tokens.add(e.getToken())); return tokens; } 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)."); } } } } |
마지막으로 SecurityConfig 클래스에서 이전 글에서 생성한 인스턴스를 반환합니다. 이제 인스턴스를 반환합니다. 카우치베이스토큰스토어 대신 인메모리토큰스토어:
|
1 2 3 4 5 6 7 8 9 10 11 |
@Autowired private CouchbaseAccessTokenRepository couchbaseAccessTokenRepository; @Autowired private CouchbaseRefreshTokenRepository couchbaseRefreshTokenRepository; @Bean public TokenStore tokenStore() { return new CouchbaseTokenStore(couchbaseAccessTokenRepository, couchbaseRefreshTokenRepository); } |
다음은 전체 버전의 SecurityConfig 클래스입니다:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
@Configuration @EnableWebMvc public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private CustomUserDetailsService customUserDetailsService; @Autowired private CouchbaseAccessTokenRepository couchbaseAccessTokenRepository; @Autowired private CouchbaseRefreshTokenRepository couchbaseRefreshTokenRepository; @Autowired public void globalUserDetails(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(customUserDetailsService) .passwordEncoder(encoder()); } @Override public void configure( WebSecurity web ) throws Exception { web.ignoring().antMatchers( HttpMethod.OPTIONS, "/**" ); } @Override protected void configure(HttpSecurity http) throws Exception { http .csrf().disable() .authorizeRequests() .antMatchers("/oauth/token").permitAll() .antMatchers("/api-docs/**").permitAll() .anyRequest().authenticated() .and().anonymous().disable(); } @Bean public TokenStore tokenStore() { return new CouchbaseTokenStore(couchbaseAccessTokenRepository, couchbaseRefreshTokenRepository); } @Bean public PasswordEncoder encoder(){ return NoOpPasswordEncoder.getInstance(); } @Bean public FilterRegistrationBean corsFilter() { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); CorsConfiguration config = new CorsConfiguration(); config.setAllowCredentials(true); config.addAllowedOrigin("*"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); source.registerCorsConfiguration("/**", config); FilterRegistrationBean bean = new FilterRegistrationBean(new CorsFilter(source)); bean.setOrder(0); return bean; } @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } } |
잘했어요! 그게 우리가 해야 할 전부입니다.
액세스 토큰은 데이터베이스에서 다음과 같이 표시됩니다:
|
1 2 |
SELECT * from test where _class = 'com.bc.quicktask.standalone.model.CouchbaseAccessToken' |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
[ { "YOUR_BUCKET_NAME": { "_class": "com.bc.quicktask.standalone.model.CouchbaseAccessToken", "authentication": "rO0ABXNyAEFvcmcuc3ByaW5nZnJhbWV3b3JrLnNlY3VyaXR5Lm9hdXRoMi5wcm92aWRlci5PQXV0aDJBdXRoZW50aWNhdGlvbr1ACwIWYlITAgACTAANc3RvcmVkUmVxdWVzdHQAPExvcmcvc3ByaW5nZnJhbWV3b3JrL3NlY3VyaXR5L29hdXRoMi9wcm92aWRlci9PQXV0aDJSZXF1ZXN0O0wAEnVzZXJBdXRoZW50aWNhdGlvbnQAMkxvcmcvc3ByaW5nZnJhbWV3b3JrL3NlY3VyaXR5L2NvcmUvQXV0aGVudGljYXRpb247eHIAR29yZy5zcHJpbmdmcmFtZXdvcmsuc2VjdXJpdHkuYXV0aGVudGljYXRpb24uQWJzdHJhY3RBdXRoZW50aWNhdGlvblRva2Vu06oofm5HZA4CAANaAA1hdXRoZW50aWNhdGVkTAALYXV0aG9yaXRpZXN0ABZMamF2YS91dGlsL0NvbGxlY3Rpb247TAAHZGV0YWlsc3QAEkxqYXZhL2xhbmcvT2JqZWN0O3hwAHNyACZqYXZhLnV0aWwuQ29sbGVjdGlvbnMkVW5tb2RpZmlhYmxlTGlzdPwPJTG17I4QAgABTAAEbGlzdHQAEExqYXZhL3V0aWwvTGlzdDt4cgAsamF2YS51dGlsLkNvbGxlY3Rpb25zJFVubW9kaWZpYWJsZUNvbGxlY3Rpb24ZQgCAy173HgIAAUwAAWNxAH4ABHhwc3IAE2phdmEudXRpbC5BcnJheUxpc3R4gdIdmcdhnQMAAUkABHNpemV4cAAAAAB3BAAAAAB4cQB+AAxwc3IAOm9yZy5zcHJpbmdmcmFtZXdvcmsuc2VjdXJpdHkub2F1dGgyLnByb3ZpZGVyLk9BdXRoMlJlcXVlc3QAAAAAAAAAAQIAB1oACGFwcHJvdmVkTAALYXV0aG9yaXRpZXNxAH4ABEwACmV4dGVuc2lvbnN0AA9MamF2YS91dGlsL01hcDtMAAtyZWRpcmVjdFVyaXQAEkxqYXZhL2xhbmcvU3RyaW5nO0wAB3JlZnJlc2h0ADtMb3JnL3NwcmluZ2ZyYW1ld29yay9zZWN1cml0eS9vYXV0aDIvcHJvdmlkZXIvVG9rZW5SZXF1ZXN0O0wAC3Jlc291cmNlSWRzdAAPTGphdmEvdXRpbC9TZXQ7TAANcmVzcG9uc2VUeXBlc3EAfgAReHIAOG9yZy5zcHJpbmdmcmFtZXdvcmsuc2VjdXJpdHkub2F1dGgyLnByb3ZpZGVyLkJhc2VSZXF1ZXN0Nih6PqNxab0CAANMAAhjbGllbnRJZHEAfgAPTAARcmVxdWVzdFBhcmFtZXRlcnNxAH4ADkwABXNjb3BlcQB+ABF4cHQACG15Y2xpZW50c3IAJWphdmEudXRpbC5Db2xsZWN0aW9ucyRVbm1vZGlmaWFibGVNYXDxpaj+dPUHQgIAAUwAAW1xAH4ADnhwc3IAEWphdmEudXRpbC5IYXNoTWFwBQfawcMWYNEDAAJGAApsb2FkRmFjdG9ySQAJdGhyZXNob2xkeHA/QAAAAAAABncIAAAACAAAAAN0AApncmFudF90eXBldAAIcGFzc3dvcmR0AAljbGllbnRfaWR0AAhteWNsaWVudHQACHVzZXJuYW1ldAAGbXl1c2VyeHNyACVqYXZhLnV0aWwuQ29sbGVjdGlvbnMkVW5tb2RpZmlhYmxlU2V0gB2S0Y+bgFUCAAB4cQB+AAlzcgAXamF2YS51dGlsLkxpbmtlZEhhc2hTZXTYbNdald0qHgIAAHhyABFqYXZhLnV0aWwuSGFzaFNldLpEhZWWuLc0AwAAeHB3DAAAABA/QAAAAAAAA3QABXRydXN0dAAEcmVhZHQABXdyaXRleAFzcQB+ACJ3DAAAABA/QAAAAAAAAHhzcQB+ABc/QAAAAAAAAHcIAAAAEAAAAAB4cHBzcQB+ACJ3DAAAABA/QAAAAAAAAHhzcQB+ACJ3DAAAABA/QAAAAAAAAHhzcgBPb3JnLnNwcmluZ2ZyYW1ld29yay5zZWN1cml0eS5hdXRoZW50aWNhdGlvbi5Vc2VybmFtZVBhc3N3b3JkQXV0aGVudGljYXRpb25Ub2tlbgAAAAAAAAH0AgACTAALY3JlZGVudGlhbHNxAH4ABUwACXByaW5jaXBhbHEAfgAFeHEAfgADAXNyAB9qYXZhLnV0aWwuQ29sbGVjdGlvbnMkRW1wdHlMaXN0ergXtDynnt4CAAB4cHNyABdqYXZhLnV0aWwuTGlua2VkSGFzaE1hcDTATlwQbMD7AgABWgALYWNjZXNzT3JkZXJ4cQB+ABc/QAAAAAAABncIAAAACAAAAAR0AA1jbGllbnRfc2VjcmV0dAAIbXlzZWNyZXRxAH4AGXEAfgAacQB+ABtxAH4AHHEAfgAdcQB+AB54AHBzcgAyY29tLmJjLnF1aWNrdGFzay5zdGFuZGFsb25lLm1vZGVsLkN1c3RvbVVzZXJEZXRhaWz9dbY7wdosOwIAAkwABmdyb3Vwc3EAfgAITAAEdXNlcnQAKExjb20vYmMvcXVpY2t0YXNrL3N0YW5kYWxvbmUvbW9kZWwvVXNlcjt4cHNxAH4ACwAAAAB3BAAAAAB4c3IAJmNvbS5iYy5xdWlja3Rhc2suc3RhbmRhbG9uZS5tb2RlbC5Vc2VyWvIkR494dqQCAAdMAAljb21wYW55SWRxAH4AD0wADWV4dGVybmFsTG9naW5xAH4AD0wAAmlkcQB+AA9MAAlpc0VuYWJsZWR0ABNMamF2YS9sYW5nL0Jvb2xlYW47TAAJaXNWaXNpYmxlcQB+ADhMAAhwYXNzd29yZHEAfgAPTAAIdXNlcm5hbWVxAH4AD3hwdAAKY29tcGFueS0tMXQABm15dXNlcnQACXVzZXJJZC0tMXNyABFqYXZhLmxhbmcuQm9vbGVhbs0gcoDVnPruAgABWgAFdmFsdWV4cAFxAH4APnQACHBhc3N3b3JkdAAGbXl1c2Vy", "authenticationId": "202d6940ebd428bbe2098530c8de3958", "clientId": "myclient", "refreshToken": "7613ffc6480ae83beb8f0988ef9ecfcf", "token": { "_class": "org.springframework.security.oauth2.common.DefaultOAuth2AccessToken", "additionalInformation": {}, "expiration": 1537420023432, "refreshToken": { "_class": "org.springframework.security.oauth2.common.DefaultExpiringOAuth2RefreshToken", "expiration": 1537420023421, "value": "c44db735-403e-49d6-8a22-cfe0e29f21d3" }, "scope": [ "trust", "read", "write" ], "tokenType": "bearer", "value": "7759804c-e1c6-4d63-9520-8737f5b46dbf" }, "tokenId": "12b6bd9de380e1dfab348f4c15abb805", "username": "myuser" } } ] |
나는 사용했습니다 caelwinner's 프로젝트에 대해 특별히 감사의 말씀을 전합니다.
질문이 있으시면 언제든지 다음 주소로 트윗해 주세요. @deniswsrosa
안녕하세요 데니스,
우선, 매우 짧지만 간결한 튜토리얼에 감사드립니다. 1년이 넘었지만 잘 설명되어 있으며 훌륭한 선생님이라는 것을 인정해야 합니다.
질문과 요청이 하나 있습니다. 이 구현 OAuth2에 JWT를 추가할 수 있는지, 가능하다면 가이드를 제공해 주실 수 있나요?
회신을 기다리는 중입니다.
정말 감사합니다.