Spring Cloud OAuth2.0 微服务中配置 Jwt Token 签名/验证
关于 Jwt Token 的签名与安全性前面已经做了几篇介绍,在 IdentityServer4 中定义了 Jwt Token 与 Reference Token 两种验证方式(https://www.cnblogs.com/Irving/p/9357539.html),理论上 Spring Security OAuth 中也可以实现,在资源服务器使用 RSA 公钥(/oauth/token_key 获得公钥)验签或调用接口来验证(/oauth/check_token 缓存调用频率),思路是一样的,这篇主要说一下 Spring Security OAuth 中 Token 签名的相关实现 。
spring security oauth2 中的 endpoint(聊聊spring security oauth2的几个endpoint的认证)
- /oauth/authorize(授权端,授权码模式使用)
- /oauth/token(令牌端,获取 token)
- /oauth/check_token(资源服务器用来校验token)
- /oauth/confirm_access(用户发送确认授权)
- /oauth/error(认证失败)
- /oauth/token_key(如果使用JWT,可以获的公钥用于 token 的验签)
授权服务器配置 Token 签名
Token 可以使用对称加密算法进行签名,因此需要使用一个对称的 Key 值,用来参与签名计算,这个 Key 值存在于授权服务以及资源服务之中,并且资源服务需要验证这个签名。或者使用非对称加密算法来对 Token 进行签名,Public Key 公布在 /oauth/token_key 这个URL连接中,默认的访问安全规则是"denyAll()",即在默认的情况下它是关闭的,你可以注入一个标准的 SpEL 表达式到 AuthorizationServerSecurityConfigurer 这个配置中来将它开启(例如使用"permitAll()"来开启可能比较合适,因为它是一个公共密钥)。实现的方式主要是重写 AuthorizationServerConfigurerAdapter 的实现,签名算法可以配置对称加密方式(HS256)与非对称加密方式(RS256)两种签名的方式。
@Configuration @EnableAuthorizationServer //@Order(2) public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter { //认证管理器 @Autowired private AuthenticationManager authenticationManager; @Autowired private BCryptPasswordEncoder passwordEncoder; /* // redis @Autowired private RedisConnectionFactory connectionFactory; @Bean public RedisTokenStore tokenStore() { return new RedisTokenStore(connectionFactory); } */ @Autowired @Qualifier("dataSource") private DataSource dataSource; // @Bean(name = "dataSource") // @ConfigurationProperties(prefix = "spring.datasource") // public DataSource dataSource() { // return DataSourceBuilder.create().build(); // } /** * 令牌存储 * @return Jdbc 令牌存储对象 */ @Bean("jdbcTokenStore") public JdbcTokenStore getJdbcTokenStore() { return new JdbcTokenStore(dataSource); } // @Bean // public UserDetailsService userDetailsService(){ // return new UserService(); // } /* * 配置客户端详情信息(内存或JDBC来实现) * * */ @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { //初始化 Client 数据到 DB // clients.jdbc(dataSource) clients.inMemory() .withClient("client_1") .authorizedGrantTypes("client_credentials") .scopes("all","read", "write") .authorities("client_credentials") .accessTokenValiditySeconds(7200) .secret(passwordEncoder.encode("123456")) .and().withClient("client_2") .authorizedGrantTypes("password", "refresh_token") .scopes("all","read", "write") .accessTokenValiditySeconds(7200) .refreshTokenValiditySeconds(10000) .authorities("password") .secret(passwordEncoder.encode("123456")) .and().withClient("client_3").authorities("authorization_code","refresh_token") .secret(passwordEncoder.encode("123456")) .authorizedGrantTypes("authorization_code") .scopes("all","read", "write") .accessTokenValiditySeconds(7200) .refreshTokenValiditySeconds(10000) .redirectUris("http://localhost:8080/callback","http://localhost:8080/signin") .and().withClient("client_test") .secret(passwordEncoder.encode("123456")) .authorizedGrantTypes("all flow") .authorizedGrantTypes("authorization_code", "client_credentials", "refresh_token","password", "implicit") .redirectUris("http://localhost:8080/callback","http://localhost:8080/signin") .scopes("all","read", "write") .accessTokenValiditySeconds(7200) .refreshTokenValiditySeconds(10000); //https://github.com/spring-projects/spring-security-oauth/blob/master/spring-security-oauth2/src/test/resources/schema.sql // clients.withClientDetails(new JdbcClientDetailsService(dataSource)); } @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { endpoints.tokenStore(getJdbcTokenStore()) //.tokenStore(new RedisTokenStore(redisConnectionFactory)) .accessTokenConverter(jwtAccessTokenConverter()) //refresh_token 需要 UserDetailsService is required //.userDetailsService(userDetailsService) .allowedTokenEndpointRequestMethods(HttpMethod.GET, HttpMethod.POST) .authenticationManager(authenticationManager); } @Override public void configure(AuthorizationServerSecurityConfigurer oauthServer) { //curl -i -X POST -H "Accept: application/json" -u "client_1:123456" http://localhost:5000/oauth/check_token?token=a1478d56-ebb8-4f21-b4b6-8a9602df24ec oauthServer.tokenKeyAccess("permitAll()") //url:/oauth/token_key,exposes public key for token verification if using JWT tokens .checkTokenAccess("isAuthenticated()") //url:/oauth/check_token allow check token .allowFormAuthenticationForClients(); } /** * 定义token 签名的方式(非对称加密算法来对 Token 进行签名,也可以使用对称加密方式) * @return */ @Bean public JwtAccessTokenConverter jwtAccessTokenConverter() { //对称加密方式 JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); converter.setSigningKey("micosrv_signing_key"); return converter; // JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); // KeyPair keyPair = new KeyStoreKeyFactory( // new ClassPathResource("keystore.jks"), "foobar".toCharArray()) // .getKeyPair("test"); // converter.setKeyPair(keyPair); // return converter; } }
上述在 JwtAccessTokenConverter 中使用对称密钥来签署我们的令牌,这意味着我们需要在资源服务器使用同样的密钥(micosrv_signing_key)。当然也可以使用非对称加密的方式,在授权服务端生成公钥和密钥,客户端使用获取到的公钥到服务器做签名验证, Google 了一番很多都是使用 keytool 生成 JKS 证书的方式去做,通过查看 JwtAccessTokenConverter 的源码了解到,最终赋值是 signer 与 verifierKey ,verifierKey 是对 publicKey 进行 Base64 编码后得到的一个字符串,本质还是读取证书里面的公钥与私钥。
public void setKeyPair(KeyPair keyPair) { PrivateKey privateKey = keyPair.getPrivate(); Assert.state(privateKey instanceof RSAPrivateKey, "KeyPair must be an RSA "); signer = new RsaSigner((RSAPrivateKey) privateKey); RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); verifier = new RsaVerifier(publicKey); verifierKey = "-----BEGIN PUBLIC KEY-----\n" + new String(Base64.encode(publicKey.getEncoded())) + "\n-----END PUBLIC KEY-----"; }
当然也可以通过 openssl 来生成。查看源码可以发现setSigningKey 方法中通过字符是否包含 "-----BEGIN" 来判断是 RSA key 还是 MAC key 的。
/** * Sets the JWT signing key. It can be either a simple MAC key or an RSA key. RSA keys * should be in OpenSSH format, as produced by <tt>ssh-keygen</tt>. * * @param key the key to be used for signing JWTs. */ public void setSigningKey(String key) { Assert.hasText(key); key = key.trim(); this.signingKey = key; if (isPublic(key)) { signer = new RsaSigner(key); logger.info("Configured with RSA signing key"); } else { // Assume it's a MAC key this.verifierKey = key; signer = new MacSigner(key); } } /** * @return true if the key has a public verifier */ private boolean isPublic(String key) { return key.startsWith("-----BEGIN"); }
openssl 生成公钥私钥
[root@021rjsh00199s mnt]# openssl genrsa -out jwt.pem 2048 Generating RSA private key, 2048 bit long modulus ..........+++ .+++ e is 65537 (0x10001) [root@021rjsh00199s mnt]# openssl rsa -in jwt.pem writing RSA key -----BEGIN RSA PRIVATE KEY----- MIIEowIBAAKCAQEAm4irSNcR7CSSfXconxL4g4M4j34wTWdTv93ocMn4VmdB7rCB U/BlxXtBUf/cgLIgQhQrAPszSZSmxiEXCOkGPr4aQBQuPgmNIR95Dhbzw/ZN0Bne cAt3ZfkkDBHv8kH3kR/jYGTdwrxKeDgXGljNsTRhbjuASxPG/Z6gU1yRPCsgc2r8 NYnztWGcDWqaobqjG3/yzFmusoAboyV7asIpo4yk378LmonDNwxnOOTb2Peg5Pee lwfOwJPbftK1VOOt18zA0cchw6dHUzq9NlB8clps/VdBap9BxU3/0YoFXRIc18ny zrWo2BcY2KQqX//AJC3OAfrfDmo+BGK8E0mp8wIDAQABAoIBAENp64P45GXMPEpx eYPpfxnRqJRZh6olHSHOl087243n16YTjxrI2fPMxrU6B2Mo0d6SS0lzl/lOmzLJ aOiNyA0t7MbVeG2fSjKPJ7M5s5K+kV+fttAtyCTE5iDtLWl9ukaG4dEIJy6e2lBd T3Y2A4HJSGm1FJh2DAwl0ywOtUy0X6ki9DgXVAaCGDuoU25Rhun64dh802DZbEEJ LdorIyeJ0ovCZyNvhlZRYkAOPy3k88smYl2jE/AbZ7pCKz/XggDcjNsERm2llaa3 pNTAZQUlHu0BQrCn6J9BxtMPyduiyrE+JYqTwnYhWQ5QRe/2J8O3t0eIK9TfUQpJ DrZf00ECgYEAy/sLX8UCmERwMuaQSwoM0BHTZIc0iAsgiXbVOLua9I3Tu/mXOVdH TikjdoWLqM62bA9dN/oqzHDwvqCy6zwamjFVSmJUejf5v+52Qj64leOmDX/RC4ne L08N1nP/Y4X24Y/5zq18qvVlhOMDdydzayJFrGhkQKhJg58pRUIdenECgYEAwzLC Awr3LeUlHa+d2O6siJVmljTc8lT+qX4TvqTDH8rAC/EyKMNaTjaX6mWosZZ7qYXv EMxvQzTEzUHRXrCGlhbX8xiBlWnvpghF2GJEvP9WaU/+OCr0gItRSLPDuZ6ctzKb 3QkBEiC8ODyPRKzlA67D23S3KJB067IUV81h9KMCgYBXUqmT3is2NFYz9DBhb3P8 vyTYLGl4tArBznWJTAcSGoVCO59ZlNuZwlLEMnePVK8To6AsjpQz4UWu1ezCd4CL 8gKpTV8M01m/qL5HrcInqMU1kjpTzjmn1xf9brsuR/NgrNoseGieZ1+GfAjHwcPP YWSiYi5I38JY7pIkbCFigQKBgAnVtty8YrPXRcV3IbbaX6sKC/8pbrBvA926Unha iNJDPuXbIzHWleg26/SNZrB76oMiEmeARWLXd8r3s/rXXhCV2g+PfofurHprFEnQ ubHkE5B+zUo7L9KCMng9RnFFwpOgYyYB3CHzsEgNFRLauzcySP/3o3rRvHJbqJa7 7GGNAoGBAKSBn4zq0iNWI2BUBb90icMsHEneiydGtFcEl3/Sz8vmjFZn0sjRbGoY gmP9LlQ+o7xRiJ/LTesi5BA6zCGrcdp0aeyJzCRbFc3WqjGeyLbfx1sJVVB6PnvS iKvvCOJq6kl3/opO+ybqJ8dzkEyoj8K4+fcX1+U6eW2w+vSpOosG -----END RSA PRIVATE KEY----- [root@021rjsh00199s mnt]# openssl rsa -in jwt.pem -pubout writing RSA key -----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm4irSNcR7CSSfXconxL4 g4M4j34wTWdTv93ocMn4VmdB7rCBU/BlxXtBUf/cgLIgQhQrAPszSZSmxiEXCOkG Pr4aQBQuPgmNIR95Dhbzw/ZN0BnecAt3ZfkkDBHv8kH3kR/jYGTdwrxKeDgXGljN sTRhbjuASxPG/Z6gU1yRPCsgc2r8NYnztWGcDWqaobqjG3/yzFmusoAboyV7asIp o4yk378LmonDNwxnOOTb2Peg5PeelwfOwJPbftK1VOOt18zA0cchw6dHUzq9NlB8 clps/VdBap9BxU3/0YoFXRIc18nyzrWo2BcY2KQqX//AJC3OAfrfDmo+BGK8E0mp 8wIDAQAB -----END PUBLIC KEY----- [root@021rjsh00199s mnt]#
application.yml
config: oauth2: # openssl genrsa -out jwt.pem 2048 # openssl rsa -in jwt.pem privateKey: | -----BEGIN RSA PRIVATE KEY----- MIIEowIBAAKCAQEAm4irSNcR7CSSfXconxL4g4M4j34wTWdTv93ocMn4VmdB7rCB U/BlxXtBUf/cgLIgQhQrAPszSZSmxiEXCOkGPr4aQBQuPgmNIR95Dhbzw/ZN0Bne cAt3ZfkkDBHv8kH3kR/jYGTdwrxKeDgXGljNsTRhbjuASxPG/Z6gU1yRPCsgc2r8 NYnztWGcDWqaobqjG3/yzFmusoAboyV7asIpo4yk378LmonDNwxnOOTb2Peg5Pee lwfOwJPbftK1VOOt18zA0cchw6dHUzq9NlB8clps/VdBap9BxU3/0YoFXRIc18ny zrWo2BcY2KQqX//AJC3OAfrfDmo+BGK8E0mp8wIDAQABAoIBAENp64P45GXMPEpx eYPpfxnRqJRZh6olHSHOl087243n16YTjxrI2fPMxrU6B2Mo0d6SS0lzl/lOmzLJ aOiNyA0t7MbVeG2fSjKPJ7M5s5K+kV+fttAtyCTE5iDtLWl9ukaG4dEIJy6e2lBd T3Y2A4HJSGm1FJh2DAwl0ywOtUy0X6ki9DgXVAaCGDuoU25Rhun64dh802DZbEEJ LdorIyeJ0ovCZyNvhlZRYkAOPy3k88smYl2jE/AbZ7pCKz/XggDcjNsERm2llaa3 pNTAZQUlHu0BQrCn6J9BxtMPyduiyrE+JYqTwnYhWQ5QRe/2J8O3t0eIK9TfUQpJ DrZf00ECgYEAy/sLX8UCmERwMuaQSwoM0BHTZIc0iAsgiXbVOLua9I3Tu/mXOVdH TikjdoWLqM62bA9dN/oqzHDwvqCy6zwamjFVSmJUejf5v+52Qj64leOmDX/RC4ne L08N1nP/Y4X24Y/5zq18qvVlhOMDdydzayJFrGhkQKhJg58pRUIdenECgYEAwzLC Awr3LeUlHa+d2O6siJVmljTc8lT+qX4TvqTDH8rAC/EyKMNaTjaX6mWosZZ7qYXv EMxvQzTEzUHRXrCGlhbX8xiBlWnvpghF2GJEvP9WaU/+OCr0gItRSLPDuZ6ctzKb 3QkBEiC8ODyPRKzlA67D23S3KJB067IUV81h9KMCgYBXUqmT3is2NFYz9DBhb3P8 vyTYLGl4tArBznWJTAcSGoVCO59ZlNuZwlLEMnePVK8To6AsjpQz4UWu1ezCd4CL 8gKpTV8M01m/qL5HrcInqMU1kjpTzjmn1xf9brsuR/NgrNoseGieZ1+GfAjHwcPP YWSiYi5I38JY7pIkbCFigQKBgAnVtty8YrPXRcV3IbbaX6sKC/8pbrBvA926Unha iNJDPuXbIzHWleg26/SNZrB76oMiEmeARWLXd8r3s/rXXhCV2g+PfofurHprFEnQ ubHkE5B+zUo7L9KCMng9RnFFwpOgYyYB3CHzsEgNFRLauzcySP/3o3rRvHJbqJa7 7GGNAoGBAKSBn4zq0iNWI2BUBb90icMsHEneiydGtFcEl3/Sz8vmjFZn0sjRbGoY gmP9LlQ+o7xRiJ/LTesi5BA6zCGrcdp0aeyJzCRbFc3WqjGeyLbfx1sJVVB6PnvS iKvvCOJq6kl3/opO+ybqJ8dzkEyoj8K4+fcX1+U6eW2w+vSpOosG -----END RSA PRIVATE KEY----- # openssl rsa -in jwt.pem -pubout publicKey: | -----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm4irSNcR7CSSfXconxL4 g4M4j34wTWdTv93ocMn4VmdB7rCBU/BlxXtBUf/cgLIgQhQrAPszSZSmxiEXCOkG Pr4aQBQuPgmNIR95Dhbzw/ZN0BnecAt3ZfkkDBHv8kH3kR/jYGTdwrxKeDgXGljN sTRhbjuASxPG/Z6gU1yRPCsgc2r8NYnztWGcDWqaobqjG3/yzFmusoAboyV7asIp o4yk378LmonDNwxnOOTb2Peg5PeelwfOwJPbftK1VOOt18zA0cchw6dHUzq9NlB8 clps/VdBap9BxU3/0YoFXRIc18nyzrWo2BcY2KQqX//AJC3OAfrfDmo+BGK8E0mp 8wIDAQAB -----END PUBLIC KEY-----
AuthorizationServerConfiguration
@Configuration @EnableAuthorizationServer public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter { private final Logger logger = LoggerFactory.getLogger(this.getClass()); //RSA配置 @Value("${config.oauth2.privateKey}") private String privateKey ; @Value("${config.oauth2.publicKey}") private String publicKey; ... /** * 定义token 签名的方式(非对称加密算法来对 Token 进行签名,也可以使用对称加密方式) * @return */ @Bean public JwtAccessTokenConverter jwtAccessTokenConverter() { JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); //converter.setSigningKey("micosrv_signing_key"); logger.info("jwtAccessTokenConverter privateKey :" + privateKey); converter.setSigningKey(privateKey);
converter.setVerifierKey(publicKey); return converter; // JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); // KeyPair keyPair = new KeyStoreKeyFactory( // new ClassPathResource("mytest.jks"), "mypasss".toCharArray()) // .getKeyPair("mytest"); // converter.setKeyPair(keyPair); // return converter; } }
报文
POST http://localhost:5000/oauth/token HTTP/1.1 Authorization: Basic Y2xpZW50XzE6MTIzNDU2 cache-control: no-cache Postman-Token: bc7e9113-fde5-4f29-8b98-ba256d94c8d2 User-Agent: PostmanRuntime/7.1.1 Accept: */* Host: localhost:5000 content-type: application/x-www-form-urlencoded accept-encoding: gzip, deflate content-length: 39 Connection: keep-alive grant_type=client_credentials&scope=all HTTP/1.1 200 Cache-Control: no-store Pragma: no-cache X-Content-Type-Options: nosniff X-XSS-Protection: 1; mode=block X-Frame-Options: DENY Content-Type: application/json;charset=UTF-8 Date: Mon, 06 Aug 2018 08:06:23 GMT Content-Length: 684 {"access_token":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzY29wZSI6WyJhbGwiXSwiZXhwIjoxNTMzNTQ5OTgzLCJhdXRob3JpdGllcyI6WyJjbGllbnRfY3JlZGVudGlhbHMiXSwianRpIjoiYjE0MzE4MWEtNzhlMi00MWNlLWI1MWYtMjY0OWE1MjQxMDg4IiwiY2xpZW50X2lkIjoiY2xpZW50XzEifQ.eIWdOMs1vJW8PVYOU3c6d4qqqdDm4OVsBOs4PGI_P_13yi4Ldst5I7Gk5BG5L16xHVDJ_g7lSet5WkUVm6pj6J1fHrzDQTr2Ni74901lewWNG2UonQUX0Bry1lObHolWKr5zDOds7E1fTFOkCVCMrS_8PNgN569rQlZhqAmV0J287XYb_7WVs4CRn1B9GrgdlSQX42Pryo1KJ5dMewIGKA9WAt_9-lKxOl1wvawJ9M1UQGXfn2xbgHhLiwb9-K61v0uV3kBC0J0gvV-b4hBzcHboOvf2Gy-o7rz0Pnuew5vltnFeIWdbptGTTpqVGbphXJoM2KZpoNy0xqpPNW9Q0g","token_type":"bearer","expires_in":7199,"scope":"all","jti":"b143181a-78e2-41ce-b51f-2649a5241088"}
上述 access_token 就是一个 RS256 签名的 Jwt Token, 可以在 https://jwt.io/ 使用公钥进行验签。
备注:keytool 是一个Java 数据证书的管理工具,对应 .NET 有 makecert 相应的工具。上述也可以使用 keytool 来生成密钥文件生成JKS文件(包含公钥和私钥):keytool -genkeypair -alias mytest -keyalg RSA -keypass mypass -keystore mytest.jks -storepass mypass
导出公钥 :keytool -list -rfc --keystore mytest.jks | openssl x509 -inform pem -pubkey
自定义额外信息
Payload 是 JWT 存储信息的主体,有时候需要额外的信息加到 Token 返回中,可以自定义一个 TokenEnhancer
public class TokenEnhancerConfiguration implements TokenEnhancer { private final Logger logger = LoggerFactory.getLogger(this.getClass()); @Override public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) { final Map<String, Object> additionalInfo = new HashMap<>(); additionalInfo.put("client_name", authentication.getName()); additionalInfo.put("ext_name", "irving"); // User user = (User) authentication.getUserAuthentication().getPrincipal(); // additionalInfo.put("username", user.getUsername()); // additionalInfo.put("authorities", user.getAuthorities()); ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo); return accessToken; } }
最后把这个 TokenEnhancer 加入到 TokenEnhancer 链中
@Bean public TokenEnhancer tokenEnhancer() { return new TokenEnhancerConfiguration(); } @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain(); tokenEnhancerChain.setTokenEnhancers(Arrays.asList(tokenEnhancer(), jwtAccessTokenConverter())); endpoints.tokenStore(getJdbcTokenStore()) //.tokenStore(new RedisTokenStore(redisConnectionFactory)) .tokenEnhancer(tokenEnhancerChain) .accessTokenConverter(jwtAccessTokenConverter()) //refresh_token 需要 UserDetailsService is required //.userDetailsService(userDetailsService) .allowedTokenEndpointRequestMethods(HttpMethod.GET, HttpMethod.POST) .authenticationManager(authenticationManager); }
报文(/oauth/token)
POST http://localhost:5000/oauth/token HTTP/1.1 Authorization: Basic Y2xpZW50XzE6MTIzNDU2 cache-control: no-cache Postman-Token: 36abf6fa-a60e-4537-9584-df8d2b256be8 User-Agent: PostmanRuntime/7.1.1 Accept: */* Host: localhost:5000 cookie: JSESSIONID=61E921C362A386DD340B695E9C8FD6B5 content-type: application/x-www-form-urlencoded accept-encoding: gzip, deflate content-length: 39 Connection: keep-alive grant_type=client_credentials&scope=all HTTP/1.1 200 Set-Cookie: JSESSIONID=58B603BA4BECFA193249EC5098B83C4C; Path=/; HttpOnly Cache-Control: no-store Pragma: no-cache X-Content-Type-Options: nosniff X-XSS-Protection: 1; mode=block X-Frame-Options: DENY Content-Type: application/json;charset=UTF-8 Date: Mon, 06 Aug 2018 09:48:18 GMT Content-Length: 791 {"access_token":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzY29wZSI6WyJhbGwiXSwib3JnYW5pemF0aW9uIjoiY2xpZW50XzEiLCJleHRfbmFtZSI6ImlydmluZyIsImV4cCI6MTUzMzU1MzcwNSwiYXV0aG9yaXRpZXMiOlsiY2xpZW50X2NyZWRlbnRpYWxzIl0sImp0aSI6IjNiOGU5ZTliLTg2NWYtNDU4OS05YmE2LWM2OTQ4MDRiZmUwMSIsImNsaWVudF9pZCI6ImNsaWVudF8xIn0.JFhZ0KJzQtUxMYnGjPryC_pAkKFMgg9u1fHqOLVlGhhP_8Tx-OVcsiNQSVl_-ZkHg0lTsBikr_Gtoun2fHKug7KPhLoKNvimbFdvbZjbp2SAT1TrccGNr6EZ8i1LJUjXzeroXVjLvgr2W6vwEwPaKA4M5oamujtqG86wsRDLmuFfDiWDSbUl41AH4wKJ3whPJixNPyETZes_vUeRa0tXgazRkKiP8o8SSqt39RaLGanbPqI5-2V8O_SoVQ-eFcmZxK7OkPtp-kciF1ZEKvs0nDe3RNGEo3l7KYmCSC7vuFhBD8ChmT-Kvaj-leNOMVDaNM8ob6VkYuLWY75or_Onbw","token_type":"bearer","expires_in":4806,"scope":"all","organization":"client_1","ext_name":"irving","jti":"3b8e9e9b-865f-4589-9ba6-c694804bfe01"}
报文(/oauth/token_key)
GET http://localhost:5000/oauth/token_key HTTP/1.1 Authorization: Basic Y2xpZW50XzE6MTIzNDU2 cache-control: no-cache Postman-Token: ae6b2774-77bd-4d6b-b266-1d5dc1347edc User-Agent: PostmanRuntime/7.1.1 Accept: */* Host: localhost:5000 cookie: JSESSIONID=C2FE8C3C08EAE46C65B51E3BAFD740FC accept-encoding: gzip, deflate Connection: keep-alive HTTP/1.1 200 X-Content-Type-Options: nosniff X-XSS-Protection: 1; mode=block Cache-Control: no-cache, no-store, max-age=0, must-revalidate Pragma: no-cache Expires: 0 X-Frame-Options: DENY Content-Type: application/json;charset=UTF-8 Date: Mon, 06 Aug 2018 12:05:57 GMT Content-Length: 494 {"alg":"SHA256withRSA","value":"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm4irSNcR7CSSfXconxL4\ng4M4j34wTWdTv93ocMn4VmdB7rCBU/BlxXtBUf/cgLIgQhQrAPszSZSmxiEXCOkG\nPr4aQBQuPgmNIR95Dhbzw/ZN0BnecAt3ZfkkDBHv8kH3kR/jYGTdwrxKeDgXGljN\nsTRhbjuASxPG/Z6gU1yRPCsgc2r8NYnztWGcDWqaobqjG3/yzFmusoAboyV7asIp\no4yk378LmonDNwxnOOTb2Peg5PeelwfOwJPbftK1VOOt18zA0cchw6dHUzq9NlB8\nclps/VdBap9BxU3/0YoFXRIc18nyzrWo2BcY2KQqX//AJC3OAfrfDmo+BGK8E0mp\n8wIDAQAB\n-----END PUBLIC KEY-----\n"}
报文(/oauth/check_token)
POST http://localhost:5000/oauth/check_token HTTP/1.1 Authorization: Basic Y2xpZW50XzE6MTIzNDU2 cache-control: no-cache Postman-Token: e6834301-2063-4e41-b3ae-02b121f9b946 User-Agent: PostmanRuntime/7.1.1 Accept: */* Host: localhost:5000 cookie: JSESSIONID=58B603BA4BECFA193249EC5098B83C4C accept-encoding: gzip, deflate content-type: multipart/form-data; boundary=--------------------------981103212895481753436742 content-length: 787 Connection: keep-alive ----------------------------981103212895481753436742 Content-Disposition: form-data; name="token" eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzY29wZSI6WyJhbGwiXSwib3JnYW5pemF0aW9uIjoiY2xpZW50XzEiLCJleHRfbmFtZSI6ImlydmluZyIsImV4cCI6MTUzMzU1MzcwNSwiYXV0aG9yaXRpZXMiOlsiY2xpZW50X2NyZWRlbnRpYWxzIl0sImp0aSI6IjNiOGU5ZTliLTg2NWYtNDU4OS05YmE2LWM2OTQ4MDRiZmUwMSIsImNsaWVudF9pZCI6ImNsaWVudF8xIn0.JFhZ0KJzQtUxMYnGjPryC_pAkKFMgg9u1fHqOLVlGhhP_8Tx-OVcsiNQSVl_-ZkHg0lTsBikr_Gtoun2fHKug7KPhLoKNvimbFdvbZjbp2SAT1TrccGNr6EZ8i1LJUjXzeroXVjLvgr2W6vwEwPaKA4M5oamujtqG86wsRDLmuFfDiWDSbUl41AH4wKJ3whPJixNPyETZes_vUeRa0tXgazRkKiP8o8SSqt39RaLGanbPqI5-2V8O_SoVQ-eFcmZxK7OkPtp-kciF1ZEKvs0nDe3RNGEo3l7KYmCSC7vuFhBD8ChmT-Kvaj-leNOMVDaNM8ob6VkYuLWY75or_Onbw ----------------------------981103212895481753436742-- HTTP/1.1 200 Set-Cookie: JSESSIONID=E7E593CD4A4505AA4457325668F67D56; Path=/; HttpOnly X-Content-Type-Options: nosniff X-XSS-Protection: 1; mode=block Cache-Control: no-cache, no-store, max-age=0, must-revalidate Pragma: no-cache Expires: 0 X-Frame-Options: DENY Content-Type: application/json;charset=UTF-8 Date: Mon, 06 Aug 2018 09:49:14 GMT Content-Length: 199 {"scope":["all"],"organization":"client_1","active":true,"ext_name":"irving","exp":1533553705,"authorities":["client_credentials"],"jti":"3b8e9e9b-865f-4589-9ba6-c694804bfe01","client_id":"client_1"}
Token 验签
使用上述的公钥,单元测试代码如下
public class TestJwtToken { @Test public void testJwt() { String token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzY29wZSI6WyJhbGwiXSwiZXh0X25hbWUiOiJpcnZpbmciLCJleHAiOjE1MzM1NjQwMzQsImNsaWVudF9uYW1lIjoiY2xpZW50XzEiLCJhdXRob3JpdGllcyI6WyJjbGllbnRfY3JlZGVudGlhbHMiXSwianRpIjoiMzgyMTJjNzktMDdmOS00ZGIzLTg0ZDUtNWIwNzY2ZTA4M2Y5IiwiY2xpZW50X2lkIjoiY2xpZW50XzEifQ.LzDGv2YvWWyK9x4Ks88PjvYNVzjOu3Ofce8ipWv9sUdqzRHA1vX0kYltw4tDh6sSCuSDMXXLZVnq6VvHunQpLm2B51hm33C0HX31UqpYKOqM_QKeQabRWZlSrVy5CSS3wpXlF032eM2WIKwnFnFNajVoegCF_ddWuqiyuvlu7gpbsYsQTfSev8HIrRN7xmFL6UKX-FAB---MMBIaLeURCYEmPe9e-o2elxo6B1Y0PdOxBQQp6GCXT8z30iD015v7hgtnIYhu-0r5PE001qGP2DVPnJ2k7rzEhxdRIcFZwOm5bxie3MQMI53yEi6_1a3Vi2XiAGtU1OrMU1ddfjisDQ"; // Jwt jwt = JwtHelper.decode(token); // System.out.println(jwt.toString()); String publicKey = "-----BEGIN PUBLIC KEY-----\n" + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm4irSNcR7CSSfXconxL4\n" + "g4M4j34wTWdTv93ocMn4VmdB7rCBU/BlxXtBUf/cgLIgQhQrAPszSZSmxiEXCOkG\n" + "Pr4aQBQuPgmNIR95Dhbzw/ZN0BnecAt3ZfkkDBHv8kH3kR/jYGTdwrxKeDgXGljN\n" + "sTRhbjuASxPG/Z6gU1yRPCsgc2r8NYnztWGcDWqaobqjG3/yzFmusoAboyV7asIp\n" + "o4yk378LmonDNwxnOOTb2Peg5PeelwfOwJPbftK1VOOt18zA0cchw6dHUzq9NlB8\n" + "clps/VdBap9BxU3/0YoFXRIc18nyzrWo2BcY2KQqX//AJC3OAfrfDmo+BGK8E0mp\n" + "8wIDAQAB\n" + "-----END PUBLIC KEY-----"; System.out.println(JwtHelper.decodeAndVerify(token, new RsaVerifier(publicKey))); } }
运行结果
{"alg":"RS256","typ":"JWT"} {"scope":["all"],"ext_name":"irving","exp":1533564034,"client_name":"client_1","authorities":["client_credentials"],"jti":"38212c79-07f9-4db3-84d5-5b0766e083f9","client_id":"client_1"} [256 crypto bytes]
在 Zuul 网关上添加授权认证方式
- 上述可以知道在资源服务端(Zuul)访问授权服务端 /oauth/token_key 获得公钥进行验签,后续也可以通过 scope 或自定义的字段来实现权限等功能。
- 资源服务端(Zuul)访问授权服务端 /oauth/check_token 来验证 Token 的合法性,但是得注意控制频率。
如果还是觉得 Spring cloud OAuth2 设计过于复杂(比如 token 的二进制序列化,密码的 BCryptPasswordEncoder 加密),也可以基于 Spring boot 来根据 OAuth2.0 与 JWT 的相关规范实现自己想要的功能,这样扩展性与定制化的功能可能会强一些,可以参考这篇文章:https://blog.csdn.net/neosmith/article/details/52539927。
注意:
spring-security-oauth2-autoconfigure 项目是由于版本问题为了支持 Spring Boot 2.x 的,具体看官方的文档说明。作为 Resource Server 端验证的方式需要注意的是当配置了 prefer-token-info=true (默认),资源端是验证方式是调用 /check_token 接口;当配置了 JwtToken 时,需要配置 security.oauth2.resource.jwt.key-uri(/token_key) 来获取公钥。资源端其他配置如下:
# SECURITY OAUTH2 CLIENT (OAuth2ClientProperties) security.oauth2.client.client-id= # OAuth2 client id. security.oauth2.client.client-secret= # OAuth2 client secret. A random secret is generated by default # SECURITY OAUTH2 RESOURCES (ResourceServerProperties) security.oauth2.resource.id= # Identifier of the resource. security.oauth2.resource.jwt.key-uri= # The URI of the JWT token. Can be set if the value is not available and the key is public. security.oauth2.resource.jwt.key-value= # The verification key of the JWT token. Can either be a symmetric secret or PEM-encoded RSA public key. security.oauth2.resource.jwk.key-set-uri= # The URI for getting the set of keys that can be used to validate the token. security.oauth2.resource.prefer-token-info=true # Use the token info, can be set to false to use the user info. security.oauth2.resource.service-id=resource # security.oauth2.resource.token-info-uri= # URI of the token decoding endpoint. security.oauth2.resource.token-type= # The token type to send when using the userInfoUri. security.oauth2.resource.user-info-uri= # URI of the user endpoint. # SECURITY OAUTH2 SSO (OAuth2SsoProperties) security.oauth2.sso.login-path=/login # Path to the login page, i.e. the one that triggers the redirect to the OAuth2 Authorization Server
REFER:
https://docs.spring.io/spring-security-oauth2-boot/docs/current/reference/html5/https://github.com/spring-projects/spring-security/wiki/OAuth-2.0-Migration-Guide
如何构建安全的微服务应用
https://www.cnblogs.com/exceptioneye/p/9341011.html
使用 OAuth 2 和 JWT 为微服务提供安全保障
https://segmentfault.com/a/1190000009164779
证书及证书管理(keytool工具实例)
https://www.cnblogs.com/benwu/articles/4891758.html
https://www.jianshu.com/p/4089c9cc2dfd
https://www.cnblogs.com/xingxueliao/p/5911292.html
http://www.baeldung.com/spring-security-oauth-jwt
https://www.jianshu.com/p/2c231c96a29b
https://www.base64encode.org/
https://jwt.io/
https://stackoverflow.com/questions/29650495/how-to-verify-a-jwt-using-python-pyjwt-with-public-key