当前位置:   article > 正文

七、api接口安全设计_接口安全性设计

接口安全性设计

参考 API安全接口安全设计

参考 系列学习互联网安全架构第 3 篇 —— 自定义注解,防止表单重复提交

参考 安全|API接口安全性设计(防篡改和重复调用)

参考 API接口安全设计

参考 数据加密之RSA

参考 这个轮子让SpringBoot实现api加密So Easy

为什么要设计安全的api接口

运行在外网服务器的接口暴露在整个互联网中,可能会受到各种攻击,例如恶意爬取服务器数据、恶意篡改请求数据等,因此需要一个机制去保证api接口是相对安全的。

本项目api接口安全设计

本项目api接口的安全性主要是为了请求参数不会被篡改和防止接口被多次调用而产生脏数据,实现方案主要围绕令牌(token)、时间戳(timestamp)、签名(signature)三个机制展开设计。

模拟前端签名与后端验证签名

RSA密钥对生成

KeyPairGenerator生成RSA密钥对

自定义Generator密钥生成器:

  1. package com.admin.utils;
  2. import java.security.KeyPair;
  3. import java.security.KeyPairGenerator;
  4. import java.security.interfaces.RSAPrivateKey;
  5. import java.security.interfaces.RSAPublicKey;
  6. import org.apache.tomcat.util.codec.binary.Base64;
  7. /**
  8. * 描述:密钥生成器
  9. */
  10. public class Generator {
  11. public static final String ALGORITHM_RSA = "RSA";
  12. private static final String RSA_CHARSET = "UTF-8";
  13. public static void main(String[] args) throws Exception {
  14. KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance(ALGORITHM_RSA);
  15. keyPairGen.initialize(1024);
  16. // 生成密钥对
  17. KeyPair keyPair = keyPairGen.generateKeyPair();
  18. // 获取公钥
  19. RSAPublicKey rsaPublicKey = (RSAPublicKey) keyPair.getPublic();
  20. byte[] keyBs = rsaPublicKey.getEncoded();
  21. String publicKey = encodeBase64(keyBs);
  22. System.out.println("生成的公钥:\r\n" + publicKey);
  23. // 获取私钥
  24. RSAPrivateKey rsaPrivateKey = (RSAPrivateKey) keyPair.getPrivate();
  25. keyBs = rsaPrivateKey.getEncoded();
  26. String privateKey = encodeBase64(keyBs);
  27. System.out.println("生成的私钥:\r\n" + privateKey);
  28. }
  29. /**
  30. * 描述:byte数组转String
  31. *
  32. * @param source
  33. * @return
  34. * @throws Exception
  35. */
  36. public static String encodeBase64(byte[] source) throws Exception {
  37. return new String(Base64.encodeBase64(source), RSA_CHARSET);
  38. }
  39. /**
  40. * 描述:String转byte数组
  41. *
  42. * @param target
  43. * @return
  44. * @throws Exception
  45. */
  46. public static byte[] decodeBase64(String target) throws Exception {
  47. return Base64.decodeBase64(target.getBytes(RSA_CHARSET));
  48. }
  49. }

测试效果:

  1. 生成的公钥:
  2. MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCoewDlVLsRQoYulINY2Szh89jWA0J7KB2RPdcMKWpX1WCPcw3wB8q/7bBrJFYr2899V59QcJpd7LRf6hEjLmjaz+7QzDhWFz2x11kqROmy0+PWt+SiSlRkGGWmfQGwUF8waAB/fzaQ5V0naDDF48XbAcvAR7DKOnYBcwpRhEHd3wIDAQAB
  3. 生成的私钥:
  4. MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAKh7AOVUuxFChi6Ug1jZLOHz2NYDQnsoHZE91wwpalfVYI9zDfAHyr/tsGskVivbz31Xn1Bwml3stF/qESMuaNrP7tDMOFYXPbHXWSpE6bLT49a35KJKVGQYZaZ9AbBQXzBoAH9/NpDlXSdoMMXjxdsBy8BHsMo6dgFzClGEQd3fAgMBAAECgYAXH8LQtx9x0AKgtAuPD0fEv3Y8cXgXdTsRqz4v0iNhaMz3A2CfWEJws0vqeLNHE8VXu8YHAV1+lLVxEKxHeuAzJjJlekGSTZAkfgF4Xm5mdfGuhVRF4ldHHbguQ5oT+OhdsxC3mMAUIwSjAkhuqEU/tK3quZeKt4Z/V3s2qa4fiQJBAPjtQSjHmnhEHayIaIiyWm/Nu+d3plq3tZsu1Mz8yj4oTPQ/rt4MvcMbI2G/tSXh1fzWmleQDlc1sATP27lzdn0CQQCtRJDX+gJejoJ9PMf/0Nzrqr1LH64UaBL0MrFc58vGN0UHtMkwKC1g58R5x006b3m8pp1mUNKutR9tMB/80SiLAkEArqECzTD6VNS0XI11iDBW8YhLAh8WPR4T8UHxV70fxGtRUSg77NrTZURsle5/jovYKwACVttgtB2d1kJbysYNoQJAZ/coYi+llE82hScfaqRMqyv8AUO1FJGOLfDs864yW3F2fjVAMyEoeWkYP2oTMOkKxuPCtk3w3NvZS48A4pYuGQJALU19SKEWk+JGC8tIOdO/tE2rSp/Uc+S/trbOkmSD0vA8sCetJ4jth68eNf60csGXDF2s4fZnc/8vyhkyX51gtg==

使用keytool生成RSA密钥对

keytool口令:生成一个名称为jwt.jks、别名为jwt的RSA密钥证书

keytool -genkey -alias jwt -keyalg RSA -keystore jwt.jks

测试类:

  1. package com.jwt.test;
  2. import com.alibaba.nacos.common.codec.Base64;
  3. import lombok.extern.slf4j.Slf4j;
  4. import org.junit.Test;
  5. import org.junit.runner.RunWith;
  6. import org.springframework.beans.factory.annotation.Autowired;
  7. import org.springframework.boot.test.context.SpringBootTest;
  8. import org.springframework.context.annotation.Bean;
  9. import org.springframework.context.annotation.Configuration;
  10. import org.springframework.core.io.ClassPathResource;
  11. import org.springframework.security.rsa.crypto.KeyStoreKeyFactory;
  12. import org.springframework.test.context.junit4.SpringRunner;
  13. import java.security.KeyPair;
  14. import java.security.interfaces.RSAPrivateKey;
  15. import java.security.interfaces.RSAPublicKey;
  16. @Slf4j
  17. @SpringBootTest
  18. @RunWith(SpringRunner.class)
  19. public class SecurityUtilTest {
  20. @Configuration
  21. public static class KeyPairConfig {
  22. @Bean
  23. public KeyPair keyPair() {
  24. //从classpath下的证书中获取秘钥对
  25. KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("jwt.jks"), "yushanma".toCharArray());
  26. return keyStoreKeyFactory.getKeyPair("jwt", "yushanma".toCharArray());
  27. }
  28. }
  29. @Autowired
  30. private KeyPair keyPair;
  31. @Test
  32. public void test() throws Exception {
  33. RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
  34. log.info("公钥信息 => \n {} \n 公钥:{}", publicKey.toString(), encodeBase64(publicKey.getEncoded()));
  35. RSAPrivateKey priKey = (RSAPrivateKey) keyPair.getPrivate();
  36. log.info("私钥信息 => \n {} \n 私钥:{}", priKey.toString(), encodeBase64(priKey.getEncoded()));
  37. }
  38. /**
  39. * byte数组转String
  40. *
  41. * @param source
  42. * @return
  43. * @throws Exception
  44. */
  45. public static String encodeBase64(byte[] source) throws Exception {
  46. return new String(Base64.encodeBase64(source), "UTF-8");
  47. }
  48. }

测试效果:

  1. 2021-06-27 19:16:56.763 INFO [service-oauth2-auth,,,] 9660 --- [ main] com.jwt.test.SecurityUtilTest : 公钥信息 =>
  2. Sun RSA public key, 2048 bits
  3. params: null
  4. modulus: 20567319899170327840617709116508613419444179747880097355983274966140263504127642335368881422806411882720105980898002775462182893840998598143067464437617108429341174218868746433850709448012734699906126672691529515616716370127079548559157542234879717092569729835437000192116554597443494711257376818024341910971296796534185532013474935580582324311859637511618273147841849708608228263695599394403252262972361043417011840182980810496043340558930295790493636277300954108517771868053504903170991016889800274282430555222393346553171856856567070173388221305727470309637173891604587252568686869543168050575459512799421310277329
  5. public exponent: 65537
  6. 公钥:MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAouyvwqbhaqIdIU4kRYTv43j04XtmGZywCcdV3I+Zer6hNHRad5hhN69WSEzFlTiXx2/6ktBwtNSM8wRCNjC3RQFK8BNSkaapHN5X7uQeP4TVrsl+/ow4D0Hgc6+GBcAYjKdXdxv4rkuy7s+mQVgCCA2zra4bopAZCKS6uM1f/C1Ki7UTCmTiM7LQdKSQsAio7MjZSKX6T1FAmr7AhjXxxUO80h8YInyboIhc7OyGevhicw7ebciVcz0ATRBrC/9Y4sf4OQV5t+g92vqcVGt2Rhczh8gQnEETVipAlH0W/syF0Xu2zr6HBtOxGP30Szg9jAytX4cL3G3WCwv47+WW0QIDAQAB
  7. 2021-06-27 19:16:56.766 INFO [service-oauth2-auth,,,] 9660 --- [ main] com.jwt.test.SecurityUtilTest : 私钥信息 =>
  8. SunRsaSign RSA private CRT key, 2048 bits
  9. params: null
  10. modulus: 20567319899170327840617709116508613419444179747880097355983274966140263504127642335368881422806411882720105980898002775462182893840998598143067464437617108429341174218868746433850709448012734699906126672691529515616716370127079548559157542234879717092569729835437000192116554597443494711257376818024341910971296796534185532013474935580582324311859637511618273147841849708608228263695599394403252262972361043417011840182980810496043340558930295790493636277300954108517771868053504903170991016889800274282430555222393346553171856856567070173388221305727470309637173891604587252568686869543168050575459512799421310277329
  11. private exponent: 19303536124600864633358183739817886254024619623908704828822363598689100937468777637800630122172549779607148921754675232596531269361731903684637376406576870157144447048272221693793691169068820840002225485409096853770911453476916292046840417212680644496451837621156444173781059146569866788065386698760658421102988458584474980507935585707721550065745072389564711878488207910700168001180802928703831440170322346912658552971247917296991274641100029517759307731419438082221755651445909904889771910842153508992367099498241878354429306538249813955752652096678699781581144570632109930711682397263860234110176066187681387955073
  12. 私钥:MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCi7K/CpuFqoh0hTiRFhO/jePThe2YZnLAJx1Xcj5l6vqE0dFp3mGE3r1ZITMWVOJfHb/qS0HC01IzzBEI2MLdFAUrwE1KRpqkc3lfu5B4/hNWuyX7+jDgPQeBzr4YFwBiMp1d3G/iuS7Luz6ZBWAIIDbOtrhuikBkIpLq4zV/8LUqLtRMKZOIzstB0pJCwCKjsyNlIpfpPUUCavsCGNfHFQ7zSHxgifJugiFzs7IZ6+GJzDt5tyJVzPQBNEGsL/1jix/g5BXm36D3a+pxUa3ZGFzOHyBCcQRNWKkCUfRb+zIXRe7bOvocG07EY/fRLOD2MDK1fhwvcbdYLC/jv5ZbRAgMBAAECggEBAJjp2JSzGEKC4SBPPQ/ak2RGNGAk91D2lOq4okeep4hivt6Cjh5NcIFZGXxGQfOp6BqRaPa+l+nAzIGR76r40in76p+lIwv9BiBINvPKOvGW9Q9VotG6PStkwwsRJJLlFqV8skTihebguZIWZo5R0aZJZeiOzvUmlbhdE7s7VulQ+8E24YcBUuwXGwOGFtmwymnCLZDPcjWZMWsujxEjPOoEqkUGCFDSKi7t9Ydi/D6yBhkAv7jnZggY1CnMfCt2M9nkpW6AUde+YPlKAKFltSNvEJnuYir9LI645wYUz5E8up13p4vvrDL+2X2CEwbVwEISw4aN36xqY7+J+30iw4ECgYEA8x0PcoNYv2lZlo5Tnu94aIWTEog6DhA6qykX4GUxd4P6vHsKyfIqGl1uDizhZNAOMu89wrENRWaJaSU0ZF5C37aCihizeNHbyR9gk/CkUeBaphCckTUvQcu9O784L39/OXKsGYcSmJRyVzknLVo+R+ZNrfvVjr2aLxvaQEyfb3kCgYEAq49/okjKL7QB2JufJ1eRZDoK2mROPntSc/bAFD7wwhWpX6hpD8XiGpMWBvnxKaKIE9EzPDXDnbJTbqhV3MbQfrudOYGk0OlPiO+pzoqft9uknPGXzqPT/8nY9q/QAW8nBp++hXNTO19/VwJtJ0+hye5q2FyamOM4K5NABpHAVBkCgYEAjx5beqlyNHTbhbNR7O3C7507AJzruF27fAmcAcDwxxAOKqkwp8QFHzJDWNr48XU99qQ6soOycVm0qQ568l8/dR2naY6zEPxSK+tp2o2+3mh6VOrQkPdDU7OSOjsO439mMTadtAV9YA975HdD5gILSh59OmBXz0k1HGiEKngxH9kCgYAXXRJ3qkwGlRAPTJovBGjjalgiB7j0H11KN5dO6odlFwga49dy83LoRZGhX5ZtIhpAAKRmlbfPGQLttfUDfPvV1n0B4NruLGfNcT4Bx7Ual8niKbCPzpXHZtiqN6UvHNEGwOh0ShFSq52u3sC4ssqIsnRQhMP1ADSdEo+MlXrIuQKBgFIZD+xT/fEw2O6UQ/4FJy3cIqV/6gAxwtFI4VlztEIG44TSlm9IA9WopMazuuwOZKSbb61/EVnYDQIovEE7V0DNu830cejxmREcL8xa6+rR2R6TsjEYCxxX3Gt6gP4RqrI/pZpmknqEjs+FbXRx+ThPWxWhKbAb2qiKVZNWt/Cx

新增一个签名模块模拟签名与验证

依赖:

  1. <dependencies>
  2. <!-- springboot-web-->
  3. <dependency>
  4. <groupId>org.springframework.boot</groupId>
  5. <artifactId>spring-boot-starter-web</artifactId>
  6. </dependency>
  7. <!--nacos客户端-->
  8. <dependency>
  9. <groupId>com.alibaba.cloud</groupId>
  10. <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
  11. </dependency>
  12. <!--fegin组件-->
  13. <dependency>
  14. <groupId>org.springframework.cloud</groupId>
  15. <artifactId>spring-cloud-starter-openfeign</artifactId>
  16. </dependency>
  17. <!-- Feign Client for loadBalancing -->
  18. <dependency>
  19. <groupId>org.springframework.cloud</groupId>
  20. <artifactId>spring-cloud-loadbalancer</artifactId>
  21. </dependency>
  22. <!-- 注解-->
  23. <dependency>
  24. <groupId>org.projectlombok</groupId>
  25. <artifactId>lombok</artifactId>
  26. <version>1.18.20</version>
  27. <scope>provided</scope>
  28. </dependency>
  29. <!-- 容错组件sentinel-->
  30. <dependency>
  31. <groupId>com.alibaba.cloud</groupId>
  32. <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
  33. </dependency>
  34. </dependencies>

将密钥证书放到resource目录:

主类:

  1. package com.sign;
  2. import org.springframework.boot.SpringApplication;
  3. import org.springframework.cloud.client.SpringCloudApplication;
  4. @SpringCloudApplication
  5. public class SignApplication {
  6. public static void main(String[] args) {
  7. SpringApplication.run(SignApplication.class);
  8. }
  9. }

封装的安全工具类:

  1. package com.sign.util;
  2. import org.apache.tomcat.util.codec.binary.Base64;
  3. import org.springframework.core.io.ClassPathResource;
  4. import org.springframework.security.rsa.crypto.KeyStoreKeyFactory;
  5. import javax.crypto.Cipher;
  6. import java.security.KeyFactory;
  7. import java.security.KeyPair;
  8. import java.security.PrivateKey;
  9. import java.security.PublicKey;
  10. import java.security.spec.PKCS8EncodedKeySpec;
  11. import java.security.spec.X509EncodedKeySpec;
  12. /**
  13. * 描述:安全工具类
  14. */
  15. public final class SecurityUtil {
  16. private static final String ALGORITHM_RSA = "RSA";
  17. private static final String RSA_CHARSET = "UTF-8";
  18. /**
  19. * 描述:将字符串通过RSA算法公钥加密
  20. *
  21. * @param content 需要加密的内容
  22. * @param pubKey 公钥
  23. * @return 加密后字符串
  24. * @throws Exception
  25. */
  26. private static String EncryptByRSAPubKey(String content, String pubKey) throws Exception {
  27. try {
  28. PublicKey publicKey = SecurityUtil.getRSAPubKey(pubKey);
  29. Cipher cipher = Cipher.getInstance(publicKey.getAlgorithm());
  30. cipher.init(Cipher.ENCRYPT_MODE, publicKey);
  31. cipher.update(content.getBytes(RSA_CHARSET));
  32. return SecurityUtil.encodeBase64(cipher.doFinal());
  33. } catch (Exception e) {
  34. e.printStackTrace();
  35. throw new Exception();
  36. }
  37. }
  38. /**
  39. * 描述:将字符串通过RSA算法公钥解密
  40. *
  41. * @param content 需要解密的内容
  42. * @param pubKey 公钥
  43. * @return 解密后字符串
  44. * @throws Exception
  45. */
  46. public static String DecryptByRSAPubKey(String content, String pubKey) throws Exception {
  47. try {
  48. PublicKey publicKey = SecurityUtil.getRSAPubKey(pubKey);
  49. Cipher cipher = Cipher.getInstance(publicKey.getAlgorithm());
  50. cipher.init(Cipher.DECRYPT_MODE, publicKey);
  51. cipher.update(SecurityUtil.decodeBase64(content));
  52. return new String(cipher.doFinal(), RSA_CHARSET);
  53. } catch (Exception e) {
  54. e.printStackTrace();
  55. throw new Exception();
  56. }
  57. }
  58. /**
  59. * 描述:将字符串通过RSA算法私钥加密
  60. *
  61. * @param content 需要加密的内容
  62. * @param priKey 私钥
  63. * @return 加密后字符串
  64. * @throws Exception
  65. */
  66. public static String EncryptByRSAPriKey(String content, String priKey) throws Exception {
  67. try {
  68. PrivateKey privateKey = SecurityUtil.getRSAPriKey(priKey);
  69. Cipher cipher = Cipher.getInstance(privateKey.getAlgorithm());
  70. cipher.init(Cipher.ENCRYPT_MODE, privateKey);
  71. cipher.update(content.getBytes(RSA_CHARSET));
  72. return SecurityUtil.encodeBase64(cipher.doFinal());
  73. } catch (Exception e) {
  74. e.printStackTrace();
  75. throw new Exception();
  76. }
  77. }
  78. /**
  79. * 描述:将字符串通过RSA算法私钥解密
  80. *
  81. * @param content 需要解密的内容
  82. * @param priKey 私钥
  83. * @return 解密后字符串
  84. * @throws Exception
  85. */
  86. public static String DecryptByRSAPriKey(String content, String priKey) throws Exception {
  87. try {
  88. PrivateKey privateKey = SecurityUtil.getRSAPriKey(priKey);
  89. Cipher cipher = Cipher.getInstance(privateKey.getAlgorithm());
  90. cipher.init(Cipher.DECRYPT_MODE, privateKey);
  91. cipher.update(SecurityUtil.decodeBase64(content));
  92. return new String(cipher.doFinal(), RSA_CHARSET);
  93. } catch (Exception e) {
  94. e.printStackTrace();
  95. throw new Exception();
  96. }
  97. }
  98. /**
  99. * 获取密钥对
  100. *
  101. * @return
  102. */
  103. private static KeyPair getKeyPair() {
  104. //从classpath下的证书中获取秘钥对
  105. KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("jwt.jks"), "yushanma".toCharArray());
  106. return keyStoreKeyFactory.getKeyPair("jwt", "yushanma".toCharArray());
  107. }
  108. /**
  109. * 获取公钥字符串
  110. *
  111. * @return
  112. * @throws Exception
  113. */
  114. public static String getPublicKey() throws Exception {
  115. // 获取密钥对
  116. KeyPair keyPair = getKeyPair();
  117. // 获取私钥信息
  118. PublicKey publicKey = keyPair.getPublic();
  119. // byte 转 String
  120. return encodeBase64(publicKey.getEncoded());
  121. }
  122. /**
  123. * 获取私钥字符串
  124. *
  125. * @return
  126. * @throws Exception
  127. */
  128. public static String getPrivateKey() throws Exception {
  129. // 获取密钥对
  130. KeyPair keyPair = SecurityUtil.getKeyPair();
  131. // 获取私钥信息
  132. PrivateKey privateKey = keyPair.getPrivate();
  133. // byte 转 String
  134. return SecurityUtil.encodeBase64(privateKey.getEncoded());
  135. }
  136. /**
  137. * 描述:获取RSA公钥
  138. *
  139. * @return PublicKey
  140. * @throws Exception
  141. */
  142. private static PublicKey getRSAPubKey(String pubKey) throws Exception {
  143. try {
  144. X509EncodedKeySpec publicKeySpec = new X509EncodedKeySpec(SecurityUtil.decodeBase64(pubKey));
  145. KeyFactory keyFactory = KeyFactory.getInstance(ALGORITHM_RSA);
  146. return keyFactory.generatePublic(publicKeySpec);
  147. } catch (Exception e) {
  148. e.printStackTrace();
  149. throw new Exception();
  150. }
  151. }
  152. /**
  153. * 描述:获取RSA私钥
  154. *
  155. * @param priKey 私钥
  156. * @return PrivateKey
  157. * @throws Exception
  158. */
  159. private static PrivateKey getRSAPriKey(String priKey) throws Exception {
  160. try {
  161. PKCS8EncodedKeySpec privateKeySpec = new PKCS8EncodedKeySpec(SecurityUtil.decodeBase64(priKey));
  162. KeyFactory keyFactory = KeyFactory.getInstance(ALGORITHM_RSA);
  163. return keyFactory.generatePrivate(privateKeySpec);
  164. } catch (Exception e) {
  165. e.printStackTrace();
  166. throw new Exception();
  167. }
  168. }
  169. /**
  170. * base64编码
  171. *
  172. * @param source
  173. * @return
  174. * @throws Exception
  175. */
  176. public static String encodeBase64(byte[] source) throws Exception {
  177. return new String(Base64.encodeBase64(source), RSA_CHARSET);
  178. }
  179. /**
  180. * Base64解码
  181. *
  182. * @param target
  183. * @return
  184. * @throws Exception
  185. */
  186. public static byte[] decodeBase64(String target) throws Exception {
  187. return Base64.decodeBase64(target.getBytes(RSA_CHARSET));
  188. }
  189. public static void main(String[] args) throws Exception {
  190. String pubKey = getPublicKey();
  191. String priKey = getPrivateKey();
  192. String content = "age=18&name=yushanma";
  193. String s = EncryptByRSAPubKey(content, pubKey);
  194. System.out.println("公钥加密后:" + s);
  195. System.out.println("私钥解密后:" + DecryptByRSAPriKey(s, priKey));
  196. content = "age=18&name=yushanma";
  197. s = EncryptByRSAPriKey(content, priKey);
  198. System.out.println("私钥加密后:" + s);
  199. System.out.println("公钥解密后:" + DecryptByRSAPubKey(s, pubKey));
  200. }
  201. }

测试:

  1. 公钥加密后:Gx9epQIqlKTHaV7a57eUkGQ02egvT1FhvD0vblqau1ncmB8ZgyNTu29gM6N+UdgoNkQZyPYx490tekmttk6B6q307rY2P+7ADtJ0L4ZUflCTCrihYdFROtMI0ZdHd/zCOw47FE7n9IsChjpHdIvngJ7cvVCtzejC5E0w1lpH/5/Nb0JT3cEqdi6sI7ybePyq+jg5FQwmOloxKHJ8X1GxqxqVX7LgKBvpZsMrTnyZ9gJeWSbRhZXDe5de0TvOabdMvEPHxFaq3nqOM+seFSk1TLG/LRvAwJizetVV/RWCfz9hAFMZ+f2ThCS547zghuXGRqCNsARa/YumRexehpkNZQ==
  2. 私钥解密后:age=18&name=yushanma
  3. 私钥加密后:ci6hNfue3LWLhn3LLmEnKchtWOznTPi7bnhOta6JyxoFx8aMnSWKgsbc4+eW9KtTH9NC05Ol1z8ksur5PpyAy16en7P4fGubq3m8fRW44gxU3Lbwz/rSMJNu3YK/P/E2rXdg8i0MtGynhfQj6ox48PXCGjjaKW0U0YUWndArmd/aebJD3nERVuYfS/2l+o9slWWqKVDzjqSSoqLG31gfYtykDEjscSG5zEUGBO/vDdETcdmIHpzJOAEe6oMvpDogniFDl9br/9W8nFc/8C082yexYggIqlJoWpjF66ywfhefQMj5olT3M9sAJCrhzdlnLi8kvtTd/83c9HUk3Ui+fw==
  4. 公钥解密后:age=18&name=yushanma

封装签名工具类:

  1. package com.sign.util;
  2. import com.alibaba.csp.sentinel.util.StringUtil;
  3. import lombok.extern.slf4j.Slf4j;
  4. import org.apache.commons.codec.digest.DigestUtils;
  5. import java.io.UnsupportedEncodingException;
  6. import java.net.URLDecoder;
  7. import java.net.URLEncoder;
  8. import java.util.*;
  9. @Slf4j
  10. public class ApiUtil {
  11. /**
  12. * 参数排序
  13. *
  14. * @param data
  15. * @return
  16. */
  17. private static String getSortedContent(Map<String, String> data) {
  18. StringBuffer content = new StringBuffer();
  19. List<String> keys = new ArrayList<>(data.keySet());
  20. Collections.sort(keys);
  21. int index = 0;
  22. for (String key : keys) {
  23. String value = data.get(key);
  24. content.append((index == 0 ? "" : "&")).append(key).append("=").append(value);
  25. index++;
  26. }
  27. return content.toString();
  28. }
  29. /**
  30. * 参数加密
  31. *
  32. * @param data
  33. * @return
  34. * @throws Exception
  35. */
  36. public static String getSignature(Map<String, String> data) throws Exception {
  37. // 对排序好的字符串进行加密,然后转大写
  38. String summary = DigestUtils.md5Hex(getSortedContent(data)).toLowerCase();
  39. log.info("md5 summary:" + summary);
  40. // 对summary进行私钥加密
  41. return URLEncoder.encode(SecurityUtil.EncryptByRSAPriKey(summary, SecurityUtil.getPrivateKey()), "utf-8");
  42. }
  43. /**
  44. * 验证签名
  45. *
  46. * @param params
  47. * @return
  48. */
  49. public static boolean verifySign(Map<String, String> params, String sign, String signType) throws UnsupportedEncodingException {
  50. if (StringUtil.isEmpty(URLDecoder.decode(sign, "utf-8"))) {
  51. return false;
  52. }
  53. //暂不支持非RSA的签名
  54. if (StringUtil.isEmpty(signType) || !"RSA".equals(signType)) {
  55. return false;
  56. }
  57. //参与签名的数据
  58. String data = getSortedContent(params);
  59. log.info("sign data:" + data);
  60. String summary = DigestUtils.md5Hex(data).toLowerCase();
  61. log.info("sign summary:" + summary);
  62. String summaryDecode = null;
  63. try {
  64. summaryDecode = SecurityUtil.DecryptByRSAPubKey(URLDecoder.decode(sign, "utf-8"), SecurityUtil.getPublicKey());
  65. } catch (Exception e) {
  66. throw new RuntimeException("do_digest_error", e);
  67. }
  68. return summary.equals(summaryDecode);
  69. }
  70. public static void main(String[] args) throws Exception {
  71. Map<String,String> data = new HashMap<>();
  72. data.put("name","yushanma");
  73. data.put("age","20");
  74. String sign = getSignature(data);
  75. log.info("参数签名:{}",sign);
  76. String signType = "RSA";
  77. log.info("验证结果:{}",verifySign(data,sign,signType));
  78. }
  79. }

测试:

  1. 21:07:33.131 [main] INFO com.commons.util.ApiUtil - md5 summary:da034d300b12c71b6480bf232d09fe34
  2. 21:07:34.964 [main] INFO com.commons.util.ApiUtil - 参数签名:E8tHAwV736SJi9OU8U86%2FBJHGbJxMBgUgOkR5%2BdARtW1MO4WtcosLv97vRWKuslpfrV1AD9%2FxOD4Jm65zczUXZIjzWFcGGI8S9NODHteAC7D7EUj9Szb0%2FA%2Bn0OZac1ev0dtXCY27FGr5MpFSziLJGkAxyhG56MxmwBpsimm9o3BuQrfaaT6CYj2JNqeDZj4Aqk9HkqtCxaS5GIAx9vud6wVxzIDBBUEWzjBbh%2FxAuUIQl8AjcNC6W8yfh1%2FKli98Ghc4hesrrRXT3t6OlXkJSfb56i0n9I19ulbld0SC96RL9CI0B%2Bvc8FIkq9s272v07M9Q6u%2F1LTgSFqj01819Q%3D%3D
  3. 21:07:34.972 [main] INFO com.commons.util.ApiUtil - sign data:age=20&name=yushanma
  4. 21:07:34.973 [main] INFO com.commons.util.ApiUtil - sign summary:da034d300b12c71b6480bf232d09fe34
  5. 21:07:35.119 [main] INFO com.commons.util.ApiUtil - 验证结果:true

控制器签名接口与校验接口:

  1. package com.sign.controller;
  2. import com.sign.util.ApiUtil;
  3. import org.springframework.web.bind.annotation.GetMapping;
  4. import org.springframework.web.bind.annotation.RequestParam;
  5. import org.springframework.web.bind.annotation.RestController;
  6. import javax.servlet.http.HttpServletRequest;
  7. import java.io.UnsupportedEncodingException;
  8. import java.util.HashMap;
  9. import java.util.Map;
  10. @RestController
  11. public class SignController {
  12. /**
  13. * 模拟前端参数签名
  14. * @param data
  15. * @return
  16. * @throws Exception
  17. */
  18. @GetMapping("/sign/getSign")
  19. public Map<String, Object> getSign(@RequestParam Map<String, String> data) throws Exception {
  20. // 返回信息
  21. Map<String, Object> result = new HashMap<>();
  22. result.put("code", 0);// 状态码
  23. result.put("msg", "success");// 信息
  24. String sign = ApiUtil.getSignature(data);
  25. result.put("data", sign);// 数据
  26. result.put("verify",ApiUtil.verifySign(data,sign,"RSA"));
  27. return result;
  28. }
  29. /**
  30. * 模拟后端校验签名
  31. * @param request
  32. * @param data
  33. * @return
  34. * @throws UnsupportedEncodingException
  35. */
  36. @GetMapping("/sign/verifySign")
  37. public boolean verifySign(HttpServletRequest request, @RequestParam Map<String, String> data) throws UnsupportedEncodingException {
  38. String sign = request.getHeader("sign");
  39. String signType = request.getHeader("sign_type");
  40. return ApiUtil.verifySign(data, sign, signType);
  41. }
  42. }

yml相关配置:

  1. server:
  2. port: 8011
  3. spring:
  4. application:
  5. name: service-sign-center
  6. cloud:
  7. nacos:
  8. discovery:
  9. server-addr: localhost:8848
  10. sentinel:
  11. transport:
  12. port: 9999
  13. dashboard: localhost:8080

模拟前端签名测试:

模拟后端签名校验测试:

这里我将sign放到header中,是因为签名signature中有些字符是特殊字符,放到parameter中可能会导致有些字符被过滤,最终在校验签名时抛出签名长度不够的错误,这个问题可以通过url编码解决:

  1. java中URL 的编码和解码函数 :
  2. java.net.URLEncoder.encode(String s)和java.net.URLDecoder.decode(String s);
  3. 在javascript 中URL 的编码和解码函数 :
  4. escape(String s)和unescape(String s);
  5. 编码的格式为:%加字符的ASCII码,即一个百分号%,后面跟对应字符的ASCII(16进制)码值。例如 空格的编码值是"%20"

当参数被修改时,签名验证就会返回false:

至此,通过signature签名机制,我们可以保证请求参数不会被修改,但这不能保证接口不被重复调用,因此还需要token、timestamp来辅助。

加入token防止表单重复提交

模拟重复提交表单

测试控制器

模拟高并发测试接口:

  1. import lombok.extern.slf4j.Slf4j;
  2. import org.springframework.web.bind.annotation.PostMapping;
  3. import org.springframework.web.bind.annotation.RequestParam;
  4. import org.springframework.web.bind.annotation.RestController;
  5. import java.util.HashMap;
  6. import java.util.Map;
  7. @Slf4j
  8. @RestController
  9. public class TestController {
  10. /**
  11. * 模拟高并发重复提交
  12. * @param data
  13. * @return
  14. */
  15. @PostMapping("/form/repeatSubmitTest")
  16. public Map<String, Object> repeatSubmitTest(@RequestParam Map<String, String> data) {
  17. // 模拟提交表单信息
  18. Map<String, Object> result = new HashMap<>();
  19. result.put("code", 0);// 状态码
  20. result.put("msg", "success");// 信息
  21. log.info("提交表单[]");
  22. return result;
  23. }
  24. }

jmeter测试组

开200个线程请求测试接口:

运行测试

表单被重复提交:

解决表单重复提交思路

前端在提交表单之前,先调用后端接口获取临时全局唯一的token,将token存入header,最后才提交表单。

后端生成token时,将token暂时缓存在redis中,设置一个有效期。当后端收到表单提交的请求时,先判断header的 token 是否在缓存中:如果在,则继续处理业务逻辑,并且处理完业务后,删除缓存中的 token;如果不在,说明无效或者重复提交。

加入依赖

redis依赖:

  1. <!-- redis 依赖-->
  2. <!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-data-redis -->
  3. <dependency>
  4. <groupId>org.springframework.boot</groupId>
  5. <artifactId>spring-boot-starter-data-redis</artifactId>
  6. <exclusions>
  7. <exclusion>
  8. <groupId>io.lettuce</groupId>
  9. <artifactId>lettuce-core</artifactId>
  10. </exclusion>
  11. </exclusions>
  12. </dependency>
  13. <dependency>
  14. <groupId>redis.clients</groupId>
  15. <artifactId>jedis</artifactId>
  16. </dependency>

封装的 Redis 操作类

  1. package com.sign.util;
  2. import org.springframework.beans.factory.annotation.Autowired;
  3. import org.springframework.data.redis.core.StringRedisTemplate;
  4. import org.springframework.stereotype.Component;
  5. import java.util.concurrent.TimeUnit;
  6. @Component
  7. public class RedisTemplateUtil {
  8. @Autowired
  9. private StringRedisTemplate stringRedisTemplate;
  10. /**
  11. * 设置 String 对象
  12. * @param key
  13. * @param data
  14. * @param timeout
  15. * @return
  16. */
  17. public Boolean setString(String key, Object data, Long timeout) {
  18. if (data instanceof String) {
  19. if (null != timeout) {
  20. stringRedisTemplate.opsForValue().set(key, (String) data, timeout, TimeUnit.SECONDS);
  21. } else {
  22. stringRedisTemplate.opsForValue().set(key, (String) data);
  23. }
  24. return true;
  25. } else {
  26. return false;
  27. }
  28. }
  29. /**
  30. * 获取 String 对象
  31. * @param key
  32. * @return
  33. */
  34. public Object getString(String key) {
  35. return stringRedisTemplate.opsForValue().get(key);
  36. }
  37. /**
  38. * 删除某个 key
  39. * @param key
  40. */
  41. public void delKey(String key) {
  42. stringRedisTemplate.delete(key);
  43. }
  44. }

token 工具类

  1. package com.sign.util;
  2. import org.springframework.beans.factory.annotation.Autowired;
  3. import org.springframework.stereotype.Component;
  4. import org.springframework.util.StringUtils;
  5. import java.util.Objects;
  6. import java.util.UUID;
  7. @Component
  8. public class TokenUtil {
  9. @Autowired
  10. private RedisTemplateUtil redisTemplateUtil;
  11. // 时间为 秒L ,如 30分钟 应为 60*30L ,这里设置 1分钟
  12. private static final Long TIMEOUT = 60L;
  13. /**
  14. * 生成 token
  15. * @return
  16. */
  17. public String getToken() {
  18. StringBuilder token = new StringBuilder("token_");
  19. token.append(UUID.randomUUID().toString().replaceAll("-", ""));
  20. redisTemplateUtil.setString(token.toString(), token.toString(), TIMEOUT);
  21. return token.toString();
  22. }
  23. /**
  24. * 判断是否有 token
  25. * @param tokenKey
  26. * @return
  27. */
  28. public Boolean findToken(String tokenKey) {
  29. if (Objects.nonNull(redisTemplateUtil.getString(tokenKey))) {
  30. String token = redisTemplateUtil.getString(tokenKey).toString();
  31. return !StringUtils.isEmpty(token);
  32. }
  33. return false;
  34. }
  35. /**
  36. * 删除某个 key
  37. * @param key
  38. */
  39. public void deleteKey(String key) {
  40. redisTemplateUtil.delKey(key);
  41. }
  42. }

测试控制器

获取token接口、模拟业务接口:

  1. package com.sign.controller;
  2. import com.sign.util.TokenUtil;
  3. import lombok.extern.slf4j.Slf4j;
  4. import org.springframework.beans.factory.annotation.Autowired;
  5. import org.springframework.util.StringUtils;
  6. import org.springframework.web.bind.annotation.GetMapping;
  7. import org.springframework.web.bind.annotation.PostMapping;
  8. import org.springframework.web.bind.annotation.RequestParam;
  9. import org.springframework.web.bind.annotation.RestController;
  10. import javax.servlet.http.HttpServletRequest;
  11. import java.util.HashMap;
  12. import java.util.Map;
  13. @Slf4j
  14. @RestController
  15. public class tokenController {
  16. @Autowired
  17. private TokenUtil tokenUtil;
  18. /**
  19. * 获取 token
  20. * @return
  21. */
  22. @GetMapping("/getToken")
  23. public String getToken(){
  24. return tokenUtil.getToken();
  25. }
  26. /**
  27. * 模拟高并发重复提交,根据 token 缓存防止重复提交
  28. * @param data
  29. * @return
  30. */
  31. @PostMapping("/form/repeatSubmitTest")
  32. public Map<String, Object> repeatSubmitTest(HttpServletRequest request, @RequestParam Map<String, String> data) {
  33. // 返回信息
  34. Map<String, Object> result = new HashMap<>();
  35. // 检查 header 中的 token
  36. String token = request.getHeader("token");
  37. if(!StringUtils.isEmpty(token)) {
  38. if (tokenUtil.findToken(token)) {
  39. // 模拟提交表单信息
  40. // TODO Something
  41. result.put("code", 0);// 状态码
  42. result.put("msg", "success");// 信息
  43. log.info("提交表单[]");
  44. // 删除缓存中的token
  45. tokenUtil.deleteKey(token);
  46. } else {
  47. log.info("请勿重复提交[]");
  48. result.put("code", -1);// 状态码
  49. result.put("msg", "fail");// 信息
  50. }
  51. }else{
  52. log.info("表单无token[]");
  53. result.put("code", -1);// 状态码
  54. result.put("msg", "fail");// 信息
  55. }
  56. return result;
  57. }
  58. }

测试

header无token时:业务逻辑不会执行

header带token请求:阻断重复请求

正常一次请求:正常完成业务逻辑

使用注解与aop拦截实现

无论是参数签名校验还是表单token校验,以上的代码都显得太臃肿,因此采用注解与aop拦截的方式来实现参数校验和token校验,复用代码的同时,让程序变得简洁与高效。

参数签名校验

封装请求常量类

  1. package com.sign.constant;
  2. /**
  3. * 请求参数常量
  4. */
  5. public class ReqParameterConstant {
  6. // 头部
  7. public static final String HEAD = "head";
  8. // 表单
  9. public static final String FORM = "form";
  10. }

自定义签名校验注解

  1. package com.sign.annotation;
  2. import java.lang.annotation.ElementType;
  3. import java.lang.annotation.Retention;
  4. import java.lang.annotation.RetentionPolicy;
  5. import java.lang.annotation.Target;
  6. /**
  7. * 验证参数签名
  8. */
  9. @Target(value = ElementType.METHOD)
  10. @Retention(RetentionPolicy.RUNTIME)
  11. public @interface VerifySign {
  12. String type();
  13. }

aop拦截器

  1. package com.sign.component;
  2. import com.sign.annotation.VerifySign;
  3. import com.sign.constant.ReqParameterConstant;
  4. import com.sign.util.ApiUtil;
  5. import lombok.extern.slf4j.Slf4j;
  6. import org.apache.commons.lang.StringUtils;
  7. import org.aspectj.lang.ProceedingJoinPoint;
  8. import org.aspectj.lang.annotation.Around;
  9. import org.aspectj.lang.annotation.Aspect;
  10. import org.aspectj.lang.annotation.Pointcut;
  11. import org.aspectj.lang.reflect.MethodSignature;
  12. import org.springframework.beans.factory.annotation.Autowired;
  13. import org.springframework.stereotype.Component;
  14. import org.springframework.web.context.request.RequestContextHolder;
  15. import org.springframework.web.context.request.ServletRequestAttributes;
  16. import javax.servlet.http.HttpServletRequest;
  17. import javax.servlet.http.HttpServletResponse;
  18. import java.io.PrintWriter;
  19. import java.util.Enumeration;
  20. import java.util.HashMap;
  21. import java.util.Map;
  22. /**
  23. * 先定义一个切面,myPointCut,这个切面主要针对 controller 层的方法。
  24. * 环绕通知:aroundAOP,主要用于判断签名是否有效。
  25. */
  26. @Slf4j
  27. @Aspect
  28. @Component
  29. public class VerifySignAop {
  30. @Autowired
  31. private ApiUtil apiUtil;
  32. /**
  33. * 定义切入面:使用 AOP 环绕通知拦截所有访问,拦截的是 controller 层
  34. */
  35. @Pointcut("execution(public * com.sign.controller.*.*(..))")
  36. public void myPointCut() {
  37. }
  38. /**
  39. * 环绕通知,主要用于验证签名的方法上
  40. *
  41. * @param joinPoint
  42. * @return
  43. * @throws Throwable
  44. */
  45. @Around("myPointCut()")
  46. public Object aroundAOP(ProceedingJoinPoint joinPoint) throws Throwable {
  47. // 判断方法上是否有注解 @VerifySign
  48. MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
  49. VerifySign verifySign = methodSignature.getMethod().getDeclaredAnnotation(VerifySign.class);
  50. String sign = null;
  51. String signType = null;
  52. // 如果方法上有此注解,则拦截
  53. if (null != verifySign) {
  54. // 获取上下文的请求
  55. HttpServletRequest request = getRequest();
  56. String type = verifySign.type();
  57. log.info("type[] " + type);
  58. if (type.equals(ReqParameterConstant.HEAD)) {
  59. sign = request.getHeader("sign");
  60. signType = request.getHeader("sign_type");
  61. } else if (type.equals(ReqParameterConstant.FORM)) {
  62. sign = request.getParameter("sign");
  63. signType = request.getParameter("sign_type");
  64. }
  65. if (StringUtils.isBlank(sign)) {
  66. response("该请求无签名!");
  67. return null;
  68. }
  69. if (StringUtils.isBlank(signType) || !"RSA".equals(signType)) {
  70. response("无签名加密方式或签名加密方式不支持!");
  71. return null;
  72. }
  73. log.info("sign[] " + sign);
  74. // 获取请求的所有参数
  75. Map<String, String> data = new HashMap();
  76. Enumeration<String> parameterNames = request.getParameterNames();
  77. while (parameterNames.hasMoreElements()) {
  78. String key = parameterNames.nextElement();
  79. data.put(key, request.getParameter(key));
  80. }
  81. boolean verifyFlag = apiUtil.verifySign(data, sign, signType);
  82. if (!verifyFlag) {
  83. response("无效的签名!");
  84. return null;
  85. } else {
  86. log.info("有效的签名。");
  87. }
  88. }
  89. // 继续往后执行
  90. Object proceed = joinPoint.proceed();
  91. return proceed;
  92. }
  93. /**
  94. * 获取容器上下文请求
  95. *
  96. * @return
  97. */
  98. public HttpServletRequest getRequest() {
  99. ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
  100. HttpServletRequest request = attributes.getRequest();
  101. return request;
  102. }
  103. /**
  104. * 相应错误响应信息
  105. *
  106. * @param msg
  107. */
  108. private void response(String msg) {
  109. ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
  110. HttpServletResponse response = attributes.getResponse();
  111. response.setHeader("Content-type", "text/html;charset=UTF-8");
  112. try (PrintWriter writer = response.getWriter()) {
  113. writer.println(msg);
  114. } catch (Exception e) {
  115. e.printStackTrace();
  116. }
  117. }
  118. }

控制器的业务方法添加签名校验注解

  1. @VerifySign(type = ReqParameterConstant.HEAD)
  2. @GetMapping("sign/test")
  3. public String test(@RequestParam Map<String,String> data){
  4. // 模拟业务逻辑
  5. // TODO Something
  6. return "test";
  7. }

测试

参数没有被修改时:签名有效

参数被修改时:签名无效

token校验

自定义注解

  1. package com.sign.annotation;
  2. import java.lang.annotation.ElementType;
  3. import java.lang.annotation.Retention;
  4. import java.lang.annotation.RetentionPolicy;
  5. import java.lang.annotation.Target;
  6. /**
  7. * 检查表单 token 是否在缓存中
  8. */
  9. @Target(value = ElementType.METHOD)// 表示此注解用在方法上
  10. @Retention(RetentionPolicy.RUNTIME)// 注解不仅被保存到class文件中,jvm加载class文件之后,仍然存在
  11. public @interface ExistsApiToken {
  12. String type();
  13. }

aop拦截器

  1. package com.sign.component;
  2. import com.sign.annotation.ExistsApiToken;
  3. import com.sign.constant.ReqParameterConstant;
  4. import com.sign.util.TokenUtil;
  5. import lombok.Synchronized;
  6. import lombok.extern.slf4j.Slf4j;
  7. import org.apache.commons.lang.StringUtils;
  8. import org.aspectj.lang.JoinPoint;
  9. import org.aspectj.lang.ProceedingJoinPoint;
  10. import org.aspectj.lang.annotation.Around;
  11. import org.aspectj.lang.annotation.Aspect;
  12. import org.aspectj.lang.annotation.Before;
  13. import org.aspectj.lang.annotation.Pointcut;
  14. import org.aspectj.lang.reflect.MethodSignature;
  15. import org.springframework.beans.factory.annotation.Autowired;
  16. import org.springframework.stereotype.Component;
  17. import org.springframework.web.context.request.RequestContextHolder;
  18. import org.springframework.web.context.request.ServletRequestAttributes;
  19. import javax.servlet.http.HttpServletRequest;
  20. import javax.servlet.http.HttpServletResponse;
  21. import java.io.PrintWriter;
  22. /**
  23. * 先定义一个切面,myPointCut,这个切面主要针对 controller 层的方法。
  24. * <p>
  25. * 前置通知:before,主要用于获取 token。
  26. * <p>
  27. * 环绕通知:aroundAOP,主要用于判断 token 是否重复。
  28. */
  29. @Aspect
  30. @Component
  31. @Slf4j
  32. public class ExtApiAop {
  33. @Autowired
  34. private TokenUtil tokenUtil;
  35. /**
  36. * 定义切入面:使用 AOP 环绕通知拦截所有访问,拦截的是 controller 层
  37. */
  38. @Pointcut("execution(public * com.sign.controller.*.*(..))")
  39. public void myPointCut() {
  40. }
  41. /**
  42. * 环绕通知,主要用于业务逻辑的方法上
  43. *
  44. * @param joinPoint
  45. * @return
  46. * @throws Throwable
  47. */
  48. @Around("myPointCut()")
  49. @Synchronized
  50. public Object aroundAOP(ProceedingJoinPoint joinPoint) throws Throwable {
  51. // 判断方法上是否有注解 @ExistsApiToken
  52. MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
  53. ExistsApiToken extApiToken = methodSignature.getMethod().getDeclaredAnnotation(ExistsApiToken.class);
  54. String token = null;
  55. // 如果方法上有此注解,则拦截
  56. if (null != extApiToken) {
  57. // 获取上下文的请求
  58. HttpServletRequest request = getRequest();
  59. String type = extApiToken.type();
  60. log.info("type[] " + type);
  61. if (type.equals(ReqParameterConstant.HEAD)) {
  62. token = request.getHeader("token");
  63. } else if (type.equals(ReqParameterConstant.FORM)) {
  64. token = request.getParameter("token");
  65. }
  66. if (StringUtils.isBlank(token)) {
  67. response("请求失效,请勿重复提交!");
  68. return null;
  69. }
  70. log.info("token[] " + token);
  71. // 判断缓存里是否有该 token
  72. Boolean existsFlag = tokenUtil.findToken(token);
  73. log.info("existsFlag[] " + existsFlag);
  74. if (!existsFlag) {
  75. response("请求失效,请勿重复提交!");
  76. log.info("请求失效,请勿重复提交!");
  77. return null;
  78. } else {
  79. // 删除 token
  80. tokenUtil.deleteKey(token);
  81. log.info("请求有效,删除token[]");
  82. }
  83. }
  84. // 继续往后执行
  85. Object proceed = joinPoint.proceed();
  86. return proceed;
  87. }
  88. /**
  89. * 获取容器上下文请求
  90. *
  91. * @return
  92. */
  93. public HttpServletRequest getRequest() {
  94. ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
  95. HttpServletRequest request = attributes.getRequest();
  96. return request;
  97. }
  98. /**
  99. * 相应错误响应信息
  100. *
  101. * @param msg
  102. */
  103. private void response(String msg) {
  104. ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
  105. HttpServletResponse response = attributes.getResponse();
  106. response.setHeader("Content-type", "text/html;charset=UTF-8");
  107. try (PrintWriter writer = response.getWriter()) {
  108. writer.println(msg);
  109. } catch (Exception e) {
  110. e.printStackTrace();
  111. }
  112. }
  113. }

控制器添加token校验注解

  1. /**
  2. * 模拟高并发重复提交,根据 token 缓存防止重复提交
  3. * 添加自定义 @ExistsApiToken 注解通过 aop 切面进行表单 token 认证
  4. * type 类型为 head ,token 存储在 header 中
  5. * @param data
  6. * @return
  7. */
  8. @ExistsApiToken(type = ReqParameterConstant.HEAD)
  9. @PostMapping("/form/repeatSubmitTest")
  10. public Map<String, Object> repeatSubmitTest(@RequestParam Map<String, String> data) {
  11. Map<String, Object> result = new HashMap<>();
  12. // 模拟提交表单信息
  13. // TODO Something
  14. result.put("code", 0);
  15. result.put("msg", "success");
  16. return result;
  17. }

测试

模拟前端获取token:

header加入表单token:

高并发测试:

可以看到,有效提交次数为1,重复提交的表单被拦截。

线程安全问题

aop切面的环绕通知方法中使用了@Synchronized注解:同步锁(将action封装原子操作),解决线程安全问题。因为在环绕通知中,有校验token是否在缓存和删除token缓存的操作,如果线程不安全,则会导致token被删除之前,接口被多次调用,业务逻辑被多次执行,不能防止表单重复提交。线程不安全的情况如下图所示:

可以看到,有效提交次数大于1。

同时使用这两个注解

模拟业务接口

  1. @ExistsApiToken(type = ReqParameterConstant.HEAD)
  2. @VerifySign(type = ReqParameterConstant.HEAD)
  3. @GetMapping("/test")
  4. public String test(@RequestParam Map<String,String> data){
  5. // 模拟业务逻辑
  6. // TODO Something
  7. return "test";
  8. }

正常情况测试

运行正常:

重复提交测试

重复的请求失效:

修改参数

签名失效:

至此,我们可以保证请求的参数不会被修改,而且该请求只能有效提交一次。

时间戳超时机制

防止表单重复提交还可以采用时间戳超时机制:每次请求加上客户端当前的时间戳,后端接收请求时,与服务端当前时间戳做对比,若不超过3s则认为该请求有效,否则返回服务超时。这种机制还可以有效防御爬取数据,如果有人劫持URL进行DOS攻击和爬取数据,那么他最多只能使用3s。

考虑到客户端与服务端的时钟有微小的差异导致时间戳校验出错,我们采取的对齐方式是客户端第一次连接服务端时请求一个接口获取服务端的当前时间A1,再和客户端的当前时间B1做一个差异化计算(A1-B1=AB),得出差异值AB,客户端再后面的请求中都是传B1+AB给到服务端。

模拟时间戳超时机制

  1. package com.sign.controller;
  2. import lombok.extern.slf4j.Slf4j;
  3. import org.springframework.web.bind.annotation.GetMapping;
  4. import org.springframework.web.bind.annotation.RequestParam;
  5. import org.springframework.web.bind.annotation.RestController;
  6. import java.time.LocalDateTime;
  7. import java.time.ZoneOffset;
  8. import java.util.HashMap;
  9. import java.util.Map;
  10. /**
  11. * @Author: 马建生
  12. * @Date: 2021/07/02/10:20
  13. * @Description: 模拟时间戳超时机制,防御表单重复提交和数据爬取。
  14. * 使用的时区都是东8区(北京时间),这是为了防止服务器设置时区错误时导致时间不对。
  15. */
  16. @RestController
  17. @Slf4j
  18. public class TimestampController {
  19. /**
  20. * 模拟前端获取客户端当前时间,与服务端获取的时间做差异对齐,最后返回对齐后的值
  21. * 为了模拟真实情况中时钟可能不一致,这里做一个微小的时钟差异
  22. * 时间戳采用毫秒级别,客户端时间为A,服务端时间为B,差异为 A - B = AB
  23. * 每次请求附带的时间戳为 A - AB
  24. *
  25. * @return
  26. */
  27. @GetMapping("/getClientTimestamp")
  28. public Long getClientTimestamp() {
  29. // 获取客户端的时间戳
  30. // 获取秒数,比当前时间快一分钟
  31. // Long second = LocalDateTime.now().plusMinutes(1).toEpochSecond(ZoneOffset.of("+8"));
  32. // log.info("timestamp_second[] " + second);
  33. // 获取毫秒数,慢一分钟
  34. Long milliSecond = LocalDateTime.now().minusMinutes(1).toInstant(ZoneOffset.of("+8")).toEpochMilli();
  35. log.info("timestamp_milliSecond[] " + milliSecond);
  36. // 模拟差异比较,客户端时间为A,服务端时间为B,差异为 A - B = AB
  37. Long difference = milliSecond - getServerTimestamp();
  38. // 模拟时钟对齐,每次请求附带的应为该时间戳
  39. return milliSecond - difference;
  40. }
  41. /**
  42. * 模拟前端获取服务器当前时间
  43. *
  44. * @return
  45. */
  46. @GetMapping("/getServerTimestamp")
  47. public Long getServerTimestamp() {
  48. // 获取秒数
  49. Long second = LocalDateTime.now().toEpochSecond(ZoneOffset.of("+8"));
  50. log.info("timestamp_second[] " + second);
  51. // 获取毫秒数
  52. Long milliSecond = LocalDateTime.now().toInstant(ZoneOffset.of("+8")).toEpochMilli();
  53. log.info("timestamp_milliSecond[] " + milliSecond);
  54. return milliSecond;
  55. }
  56. /**
  57. * 模拟业务逻辑,先检查时间戳差异是否在可控范围,若通过则执行业务逻辑,否则返回超时信息
  58. * 为了方便,这里模拟超时 10s
  59. * @param data
  60. * @return
  61. */
  62. @GetMapping("/test")
  63. public Map<String, Object> test(@RequestParam Map<String, String> data) {
  64. // 获取参数中的时间戳
  65. Long timestamp = Long.parseLong(data.get("timestamp"));
  66. // 返回信息
  67. Map<String,Object> result = new HashMap<>();
  68. if (getServerTimestamp() - timestamp <= 10000 && getServerTimestamp() - timestamp >= 0) {
  69. // 模拟业务逻辑
  70. // TODO Something
  71. result.put("code",0);
  72. result.put("msg","success");
  73. }else{
  74. result.put("code",-1);
  75. result.put("msg","请求超时,请重新提交!");
  76. }
  77. return result;
  78. }
  79. }

测试

对齐时钟

带时间戳请求

超时请求

以注解结合aop方式实现

自定义注解:

  1. package com.sign.annotation;
  2. import java.lang.annotation.ElementType;
  3. import java.lang.annotation.Retention;
  4. import java.lang.annotation.RetentionPolicy;
  5. import java.lang.annotation.Target;
  6. /**
  7. * @Author: 马建生
  8. * @Date: 2021/07/02/11:19
  9. * @Description: 检查请求的时间戳是否超时
  10. */
  11. @Target(value = ElementType.METHOD)
  12. @Retention(RetentionPolicy.RUNTIME)
  13. public @interface CheckTimestamp {
  14. String type();
  15. }

aop拦截器:

  1. package com.sign.component;
  2. import com.sign.annotation.CheckTimestamp;
  3. import com.sign.constant.ReqParameterConstant;
  4. import lombok.Synchronized;
  5. import lombok.extern.slf4j.Slf4j;
  6. import org.aspectj.lang.ProceedingJoinPoint;
  7. import org.aspectj.lang.annotation.Around;
  8. import org.aspectj.lang.annotation.Aspect;
  9. import org.aspectj.lang.annotation.Pointcut;
  10. import org.aspectj.lang.reflect.MethodSignature;
  11. import org.springframework.stereotype.Component;
  12. import org.springframework.web.context.request.RequestContextHolder;
  13. import org.springframework.web.context.request.ServletRequestAttributes;
  14. import javax.servlet.http.HttpServletRequest;
  15. import javax.servlet.http.HttpServletResponse;
  16. import java.io.PrintWriter;
  17. import java.time.LocalDateTime;
  18. import java.time.ZoneOffset;
  19. /**
  20. * @Author: 马建生
  21. * @Date: 2021/07/02/11:21
  22. * @Description:
  23. * 先定义一个切面,myPointCut,这个切面主要针对 controller 层的方法。
  24. * 环绕通知:aroundAOP,主要用于判断 token 是否重复。
  25. *
  26. */
  27. @Aspect
  28. @Component
  29. @Slf4j
  30. public class CheckTimestampAop {
  31. /**
  32. * 定义切入面:使用 AOP 环绕通知拦截所有访问,拦截的是 controller 层
  33. */
  34. @Pointcut("execution(public * com.sign.controller.*.*(..))")
  35. public void myPointCut() {
  36. }
  37. /**
  38. * 环绕通知,主要用于业务逻辑的方法上
  39. *
  40. * @param joinPoint
  41. * @return
  42. * @throws Throwable
  43. */
  44. @Around("myPointCut()")
  45. @Synchronized
  46. public Object aroundAOP(ProceedingJoinPoint joinPoint) throws Throwable {
  47. // 判断方法上是否有注解 @CheckTimestamp
  48. MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
  49. CheckTimestamp extApiToken = methodSignature.getMethod().getDeclaredAnnotation(CheckTimestamp.class);
  50. long timestamp = 0;
  51. // 如果方法上有此注解,则拦截
  52. if (null != extApiToken) {
  53. // 获取上下文的请求
  54. HttpServletRequest request = getRequest();
  55. String type = extApiToken.type();
  56. log.info("type[] " + type);
  57. if (type.equals(ReqParameterConstant.HEAD)) {
  58. timestamp = Long.parseLong(request.getHeader("timestamp"));
  59. } else if (type.equals(ReqParameterConstant.FORM)) {
  60. timestamp = Long.parseLong(request.getParameter("timestamp"));
  61. }
  62. if (timestamp == 0) {
  63. response("请求无效,请重新提交!");
  64. return null;
  65. }
  66. long serverTimestamp = LocalDateTime.now().toInstant(ZoneOffset.of("+8")).toEpochMilli();
  67. // 判断超时
  68. if (serverTimestamp - timestamp <= 10000 && serverTimestamp - timestamp >= 0) {
  69. log.info("请求有效。");
  70. } else {
  71. response("请求超时,请重新提交!");
  72. return null;
  73. }
  74. }
  75. // 继续往后执行
  76. Object proceed = joinPoint.proceed();
  77. return proceed;
  78. }
  79. /**
  80. * 获取容器上下文请求
  81. *
  82. * @return
  83. */
  84. public HttpServletRequest getRequest() {
  85. ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
  86. HttpServletRequest request = attributes.getRequest();
  87. return request;
  88. }
  89. /**
  90. * 相应错误响应信息
  91. *
  92. * @param msg
  93. */
  94. private void response(String msg) {
  95. ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
  96. HttpServletResponse response = attributes.getResponse();
  97. response.setHeader("Content-type", "text/html;charset=UTF-8");
  98. try (PrintWriter writer = response.getWriter()) {
  99. writer.println(msg);
  100. } catch (Exception e) {
  101. e.printStackTrace();
  102. }
  103. }
  104. }

控制器的业务方法上添加注解:

  1. /**
  2. * 模拟业务逻辑,先检查时间戳差异是否在可控范围,若通过则执行业务逻辑,否则返回超时信息
  3. * 为了方便,这里模拟超时 10s
  4. *
  5. * @param data
  6. * @return
  7. */
  8. @CheckTimestamp(type = ReqParameterConstant.FORM)
  9. @GetMapping("/timestamp/test")
  10. public Map<String, Object> test(@RequestParam Map<String, String> data) {
  11. Map<String, Object> result = new HashMap<>();
  12. // 模拟业务逻辑
  13. // TODO Something
  14. result.put("code", 0);
  15. result.put("msg", "success");
  16. return result;
  17. }

测试:

带时间戳请求:

超时请求:

token与多端登录

实现思路

因为用户可以在多端登录(手机app,小程序,网页),所以一个用户可能有多个有效token。当用户修改登录密码时需要把全部的token删除,我们将username与list<token>作为键值对缓存在redis中,修改密码时会把username对应的token列表中的全部token删除。

将 token与username以键值对缓存在redis中,并设置失效时间。服务端接收到请求后进行token验证,如果token不存在,说明请求无效。

在oauth模块完成认证后,将token缓存到redis;在gateway网关模块拦截所有请求检查token是否在redis中;也可以在oauth模块checktoken过程中进行检查(可参考Spring Cloud OAuth2 资源服务器CheckToken 源码解析)。

实现细节

修改oauth2模块的控制器

生成token之后将token与username缓存到redis:应将redis操作封装为util,这里为方便未作封装

  1. package com.oauth2.controller;
  2. import cn.hutool.core.collection.CollUtil;
  3. import cn.hutool.core.convert.Convert;
  4. import com.oauth2.api.CommonResult;
  5. import com.oauth2.constant.RedisConstant;
  6. import com.oauth2.domain.dto.Oauth2TokenDto;
  7. import lombok.extern.slf4j.Slf4j;
  8. import org.springframework.beans.factory.annotation.Autowired;
  9. import org.springframework.data.redis.core.RedisTemplate;
  10. import org.springframework.security.oauth2.common.OAuth2AccessToken;
  11. import org.springframework.security.oauth2.provider.endpoint.TokenEndpoint;
  12. import org.springframework.web.HttpRequestMethodNotSupportedException;
  13. import org.springframework.web.bind.annotation.*;
  14. import javax.annotation.Resource;
  15. import java.security.Principal;
  16. import java.util.List;
  17. import java.util.Map;
  18. import java.util.concurrent.TimeUnit;
  19. @Slf4j
  20. @RestController
  21. public class AuthController {
  22. @Autowired
  23. private TokenEndpoint tokenEndpoint;
  24. @Resource
  25. private RedisTemplate<String, Object> redisTemplate;
  26. /**
  27. * Oauth2登录认证
  28. */
  29. @PostMapping("/oauth/token")
  30. public CommonResult<Oauth2TokenDto> postAccessToken(Principal principal, @RequestParam Map<String, String> parameters) throws HttpRequestMethodNotSupportedException {
  31. OAuth2AccessToken oAuth2AccessToken = tokenEndpoint.postAccessToken(principal, parameters).getBody();
  32. Oauth2TokenDto oauth2TokenDto = Oauth2TokenDto.builder()
  33. .token(oAuth2AccessToken.getValue())
  34. .refreshToken(oAuth2AccessToken.getRefreshToken().getValue())
  35. .expiresIn(oAuth2AccessToken.getExpiresIn())
  36. .tokenHead("Bearer ").build();
  37. String username = parameters.get("username");
  38. String token = oauth2TokenDto.getToken();
  39. // 将 token => username 作为键值对存入redis缓存,并设定有效时间为24小时
  40. // 生产环境时可以将 token 有效期设置长一点,以免 redis 数据频繁变动
  41. redisTemplate.opsForValue().set(token, username, 3600 * 24, TimeUnit.SECONDS);
  42. // 判断用户是否首次登录
  43. if (!redisTemplate.opsForHash().hasKey(RedisConstant.USERID_TOKEN_MAP, username)) {
  44. log.info("user log[] 首次登录");
  45. // 将 username 与 list<token> 作为键值对缓存在 redis 中
  46. redisTemplate.opsForHash().put(RedisConstant.USERID_TOKEN_MAP, username, CollUtil.toList(token));
  47. }else{
  48. // 先将之前的 token 拿出来
  49. Object object = redisTemplate.opsForHash().get(RedisConstant.USERID_TOKEN_MAP, username);
  50. List<String> userToken = Convert.toList(String.class, object);
  51. log.info("user log[] 非首次登录 tokenList => {}",userToken.toString());
  52. // 删除已有的 token
  53. redisTemplate.opsForHash().delete(RedisConstant.USERID_TOKEN_MAP,username);
  54. // 添加新增的 token
  55. userToken.add(token);
  56. // 将 username 与 list<token> 作为键值对缓存在 redis 中
  57. redisTemplate.opsForHash().put(RedisConstant.USERID_TOKEN_MAP, username, userToken);
  58. }
  59. return CommonResult.success(oauth2TokenDto);
  60. }
  61. }

修改gateway模块的AuthGlobalFilter

在网关拦截请求对token进行检查,如果不在redis缓存中,则返回401:

  1. package com.gateway.filter;
  2. import cn.hutool.core.util.StrUtil;
  3. import com.alibaba.fastjson.JSONObject;
  4. import com.nimbusds.jose.JWSObject;
  5. import lombok.extern.slf4j.Slf4j;
  6. import org.slf4j.LoggerFactory;
  7. import org.springframework.cloud.gateway.filter.GatewayFilterChain;
  8. import org.springframework.cloud.gateway.filter.GlobalFilter;
  9. import org.springframework.core.Ordered;
  10. import org.springframework.core.io.buffer.DataBuffer;
  11. import org.springframework.data.redis.core.RedisTemplate;
  12. import org.springframework.http.HttpStatus;
  13. import org.springframework.http.server.reactive.ServerHttpRequest;
  14. import org.springframework.http.server.reactive.ServerHttpResponse;
  15. import org.springframework.stereotype.Component;
  16. import org.springframework.web.server.ServerWebExchange;
  17. import reactor.core.publisher.Flux;
  18. import reactor.core.publisher.Mono;
  19. import javax.annotation.Resource;
  20. import java.nio.charset.StandardCharsets;
  21. import java.text.ParseException;
  22. //import java.util.logging.Logger;
  23. @Slf4j
  24. @Component
  25. public class AuthGlobalFilter implements GlobalFilter, Ordered {
  26. // private static Logger LOGGER = (Logger) LoggerFactory.getLogger(AuthGlobalFilter.class);
  27. @Resource
  28. private RedisTemplate<String, Object> redisTemplate;
  29. @Override
  30. public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
  31. String token = exchange.getRequest().getHeaders().getFirst("Authorization");
  32. if (StrUtil.isEmpty(token)) {
  33. return chain.filter(exchange);
  34. }
  35. try {
  36. // 从token中解析用户信息并设置到Header中去
  37. String realToken = token.replace("Bearer ", "");
  38. // 检查token是否在redis缓存中
  39. Object object = redisTemplate.opsForValue().get(realToken);
  40. if (object != null) {
  41. JWSObject jwsObject = JWSObject.parse(realToken);
  42. String userStr = jwsObject.getPayload().toString();
  43. log.info("AuthGlobalFilter.filter() user:{}", userStr);
  44. // LOGGER.info(String.format("AuthGlobalFilter.filter() user:{0}",userStr));
  45. // 将json格式的用户信息转成json对象
  46. JSONObject user = JSONObject.parseObject(userStr);
  47. // 将用户名加到header中,控制器通过HttpServletRequest的getHeader方法获取username
  48. ServerHttpRequest request = exchange.getRequest().mutate().header("username", user.getString("user_name")).build();
  49. exchange = exchange.mutate().request(request).build();
  50. } else {
  51. // 设置status和body
  52. ServerWebExchange finalExchange = exchange;
  53. return Mono.defer(() -> {
  54. finalExchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);//设置status
  55. final ServerHttpResponse response = finalExchange.getResponse();
  56. // token无效,返回401,这里可以用json对象实现
  57. byte[] bytes = "{\"code\":\"401\",\"data\":\"Access Denied\",\"message\":\"暂未登录或token已经过期\"}".getBytes(StandardCharsets.UTF_8);
  58. DataBuffer buffer = finalExchange.getResponse().bufferFactory().wrap(bytes);
  59. log.info("token不在redis缓存中,请求无效");
  60. return response.writeWith(Flux.just(buffer));//设置body
  61. });
  62. }
  63. } catch (ParseException e) {
  64. e.printStackTrace();
  65. }
  66. return chain.filter(exchange);
  67. }
  68. @Override
  69. public int getOrder() {
  70. return 0;
  71. }
  72. }

student模块 修改密码changePassword 接口

  1. /**
  2. * 修改密码接口
  3. * 需要旧密码 oldPwd 验证,旧密码通过则更新新密码 newPwd 到数据库
  4. * 撤销 redis 缓存该用户的所有有效 token
  5. *
  6. * @param request
  7. * @param oldPwd
  8. * @param newPwd
  9. * @return
  10. */
  11. @ApiOperation(
  12. value = "更新学生账号密码",
  13. notes = "需要在旧密码的校验,新密码二次校验需在前端完成"
  14. )
  15. @GlobalTransactional
  16. @GetMapping("/changePassword")
  17. public Map<String, Object> changePassword(HttpServletRequest request, @RequestParam("oldPwd") String oldPwd, @RequestParam("newPwd") String newPwd) throws Exception {
  18. // 从 header 获取 username
  19. String username = request.getHeader("username");
  20. Student student = new Student();
  21. student.setUsername(username);
  22. // 先查询该学生信息
  23. QueryWrapper<Student> studentQueryWrapper = new QueryWrapper<>(student);
  24. Student studentResult = studentMapper.selectOne(studentQueryWrapper);
  25. log.info("student => {}", studentResult.toString());
  26. // 返回信息
  27. Map<String, Object> result = new HashMap<>();
  28. // 如果旧密码匹配,则更新密码,销毁 redis 中该 user 所有有效的 token
  29. // 第一个参数为明文,第二个参数为密文
  30. if (passwordEncoder.matches(oldPwd, studentResult.getPassword())) {
  31. student.setId(studentResult.getId());
  32. // 密码需要加密
  33. student.setPassword(passwordEncoder.encode(newPwd));
  34. // 通过 id 更新
  35. if (studentMapper.updateById(student) == 0) {
  36. throw new Exception(MySQLMessageConstant.UPDATE_FAIL);
  37. }
  38. // 销毁所有有效 token
  39. Object object = redisTemplate.opsForHash().get(RedisConstant.USERID_TOKEN_MAP, username);
  40. List<String> userToken = Convert.toList(String.class, object);
  41. redisTemplate.opsForHash().delete(RedisConstant.USERID_TOKEN_MAP, username);
  42. userToken.forEach(p -> redisTemplate.delete(p));
  43. result.put("code", 0);// 状态码
  44. result.put("msg", "success");// 信息
  45. } else {
  46. result.put("code", -1);// 状态码
  47. result.put("msg", "旧密码错误!");// 信息
  48. }
  49. return result;
  50. }

测试

获取token:

  1. {
  2.     "code"200,
  3.     "message""操作成功",
  4.     "data": {
  5.         "token""eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiIxODAxNTYxNTI5QHFxLmNvbSIsInNjb3BlIjpbImFsbCJdLCJpZCI6MTQwNzYxNzg3ODI3MDM4MjA4MSwiZXhwIjoxNjI1NDY3Mjc0LCJhdXRob3JpdGllcyI6WyJTVFVERU5UIl0sImp0aSI6ImE1N2M4MDM4LTI1OTgtNGE4Yi04M2ViLTc3YWRhMDUyYzYwNSIsImNsaWVudF9pZCI6ImNsaWVudC1hcHAifQ.MKN-BgekPMu-DhmGE6F9sUxCtQWxMY7hDHBNUeHkAGfU7NO3xzWZKoyC5pLbxnO8njhSJ57UTNTPuM-kxx-lrjmu542wAqgBQTTEQHTruxR_sGbVLrqRqV0JkJf24JV2NhWgVoIvpNNgB9cOPaUdBxJ52lictfKRFjxei9bcLDH22GwkHBqzNJHnoJIUgms0vS59WwZl5eamkHWwik7RjhySje7dg4PpzaQfSO0n-Q1rfJShioEQdECsELHSKujK6mzZpPvKUpEhYXdlZjwt9jUcS5pHNeCe9CVc2RpKmDGdl6YnX-oh-jvjF2KfVqnRIqUo_fYeDxAPEGWc5YaD5A",
  6.         "refreshToken""eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiIxODAxNTYxNTI5QHFxLmNvbSIsInNjb3BlIjpbImFsbCJdLCJhdGkiOiJhNTdjODAzOC0yNTk4LTRhOGItODNlYi03N2FkYTA1MmM2MDUiLCJpZCI6MTQwNzYxNzg3ODI3MDM4MjA4MSwiZXhwIjoxNjI1NTUwMDc0LCJhdXRob3JpdGllcyI6WyJTVFVERU5UIl0sImp0aSI6IjYwNDFhYzgwLTE5YzAtNDA2MC05YWM2LTRmMTg5ZWE4N2MwZiIsImNsaWVudF9pZCI6ImNsaWVudC1hcHAifQ.E69t1dajlW3DQ0JcEbL1-n--AshAOJ0NTLe5ZZaT1LXEPgG2ENm0gztlHVAwHcK5fvl7Fx5OlaxbkKXoO4oeT0ppokXr8r6mFeNzrmBgvy6Is2PElNekkSkMG0c4pb7Syidg6vmP2MTMcwQ2hudUz0HJ6Bm0nTCkCtpxEMN9aVDVp2QOZsNQdIbqivysF3ZArEC0JghQy2Wk_e8NbB70jQaBbgXDzB2c2VqMQDLYNT8BTXTa4LEb6j8KMtGO_eWtsPxAPBPwtPrzbVvEED1Wa2ZnR9w4kOs5OCMtR_2LemtJuHBRgVxsbHeChmw55E7AOpStDTWhoVtfAx3W0J4-8A",
  7.         "tokenHead""Bearer ",
  8.         "expiresIn"3599
  9.     }
  10. }

访问信息接口:

  1. {
  2.     "msg""success",
  3.     "code"0,
  4.     "data": {
  5.         "openid"null,
  6.         "roles"null,
  7.         "updateTime"null,
  8.         "sid"11819,
  9.         "password"null,
  10.         "regTime"null,
  11.         "loginTime""2021-07-05T05:41:14",
  12.         "major"null,
  13.         "sno""pG1kj6V9zeu6GFcosJ2sEg==",
  14.         "nickname""sam",
  15.         "id"1407617878270382081,
  16.         "status"true,
  17.         "username""1801561529@qq.com"
  18.     }
  19. }

修改token缓存的ttl,使其快速过期:

token过期时重新请求:

模拟多端登录:多次请求登录接口

可以看到redis缓存中,用户有多个有效token。

修改密码测试:

输入错误的旧密码则无法修改。

旧密码正确则修改成功。

redis 清空了该用户所有的token,用户需重新登录。

使用 rsa-encrypt-body-spring-boot

正如该包所描述:Spring Boot 接口请求参数自动加解密。

引入该依赖

  1. <!-- https://mvnrepository.com/artifact/cn.shuibo/rsa-encrypt-body-spring-boot -->
  2. <dependency>
  3. <groupId>cn.shuibo</groupId>
  4. <artifactId>rsa-encrypt-body-spring-boot</artifactId>
  5. <version>1.0.1.RELEASE</version>
  6. </dependency>

 启动类 Application中 添加 @EnableSecurity 注解

  1. package com.sign;
  2. import cn.shuibo.annotation.EnableSecurity;
  3. import org.springframework.boot.SpringApplication;
  4. import org.springframework.cloud.client.SpringCloudApplication;
  5. @EnableSecurity
  6. @SpringCloudApplication
  7. public class SignApplication {
  8. public static void main(String[] args) {
  9. SpringApplication.run(SignApplication.class);
  10. }
  11. }

在application.yml或者application.properties中添加RSA公钥及私钥

  1. 生成的公钥:
  2. MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCoewDlVLsRQoYulINY2Szh89jWA0J7KB2RPdcMKWpX1WCPcw3wB8q/7bBrJFYr2899V59QcJpd7LRf6hEjLmjaz+7QzDhWFz2x11kqROmy0+PWt+SiSlRkGGWmfQGwUF8waAB/fzaQ5V0naDDF48XbAcvAR7DKOnYBcwpRhEHd3wIDAQAB
  3. 生成的私钥:
  4. MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAKh7AOVUuxFChi6Ug1jZLOHz2NYDQnsoHZE91wwpalfVYI9zDfAHyr/tsGskVivbz31Xn1Bwml3stF/qESMuaNrP7tDMOFYXPbHXWSpE6bLT49a35KJKVGQYZaZ9AbBQXzBoAH9/NpDlXSdoMMXjxdsBy8BHsMo6dgFzClGEQd3fAgMBAAECgYAXH8LQtx9x0AKgtAuPD0fEv3Y8cXgXdTsRqz4v0iNhaMz3A2CfWEJws0vqeLNHE8VXu8YHAV1+lLVxEKxHeuAzJjJlekGSTZAkfgF4Xm5mdfGuhVRF4ldHHbguQ5oT+OhdsxC3mMAUIwSjAkhuqEU/tK3quZeKt4Z/V3s2qa4fiQJBAPjtQSjHmnhEHayIaIiyWm/Nu+d3plq3tZsu1Mz8yj4oTPQ/rt4MvcMbI2G/tSXh1fzWmleQDlc1sATP27lzdn0CQQCtRJDX+gJejoJ9PMf/0Nzrqr1LH64UaBL0MrFc58vGN0UHtMkwKC1g58R5x006b3m8pp1mUNKutR9tMB/80SiLAkEArqECzTD6VNS0XI11iDBW8YhLAh8WPR4T8UHxV70fxGtRUSg77NrTZURsle5/jovYKwACVttgtB2d1kJbysYNoQJAZ/coYi+llE82hScfaqRMqyv8AUO1FJGOLfDs864yW3F2fjVAMyEoeWkYP2oTMOkKxuPCtk3w3NvZS48A4pYuGQJALU19SKEWk+JGC8tIOdO/tE2rSp/Uc+S/trbOkmSD0vA8sCetJ4jth68eNf60csGXDF2s4fZnc/8vyhkyX51gtg==
  1. server:
  2. port: 8011
  3. spring:
  4. redis:
  5. host: localhost
  6. port: 6379
  7. database: 0
  8. application:
  9. name: service-sign-center
  10. cloud:
  11. nacos:
  12. discovery:
  13. server-addr: localhost:8848
  14. sentinel:
  15. transport:
  16. port: 9999
  17. dashboard: localhost:8080
  18. # api 加密
  19. rsa:
  20. encrypt:
  21. open: true # 是否开启加密 true or false
  22. showLog: true # 是否打印加解密log true or false
  23. publicKey: MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCoewDlVLsRQoYulINY2Szh89jWA0J7KB2RPdcMKWpX1WCPcw3wB8q/7bBrJFYr2899V59QcJpd7LRf6hEjLmjaz+7QzDhWFz2x11kqROmy0+PWt+SiSlRkGGWmfQGwUF8waAB/fzaQ5V0naDDF48XbAcvAR7DKOnYBcwpRhEHd3wIDAQAB
  24. privateKey: MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAKh7AOVUuxFChi6Ug1jZLOHz2NYDQnsoHZE91wwpalfVYI9zDfAHyr/tsGskVivbz31Xn1Bwml3stF/qESMuaNrP7tDMOFYXPbHXWSpE6bLT49a35KJKVGQYZaZ9AbBQXzBoAH9/NpDlXSdoMMXjxdsBy8BHsMo6dgFzClGEQd3fAgMBAAECgYAXH8LQtx9x0AKgtAuPD0fEv3Y8cXgXdTsRqz4v0iNhaMz3A2CfWEJws0vqeLNHE8VXu8YHAV1+lLVxEKxHeuAzJjJlekGSTZAkfgF4Xm5mdfGuhVRF4ldHHbguQ5oT+OhdsxC3mMAUIwSjAkhuqEU/tK3quZeKt4Z/V3s2qa4fiQJBAPjtQSjHmnhEHayIaIiyWm/Nu+d3plq3tZsu1Mz8yj4oTPQ/rt4MvcMbI2G/tSXh1fzWmleQDlc1sATP27lzdn0CQQCtRJDX+gJejoJ9PMf/0Nzrqr1LH64UaBL0MrFc58vGN0UHtMkwKC1g58R5x006b3m8pp1mUNKutR9tMB/80SiLAkEArqECzTD6VNS0XI11iDBW8YhLAh8WPR4T8UHxV70fxGtRUSg77NrTZURsle5/jovYKwACVttgtB2d1kJbysYNoQJAZ/coYi+llE82hScfaqRMqyv8AUO1FJGOLfDs864yW3F2fjVAMyEoeWkYP2oTMOkKxuPCtk3w3NvZS48A4pYuGQJALU19SKEWk+JGC8tIOdO/tE2rSp/Uc+S/trbOkmSD0vA8sCetJ4jth68eNf60csGXDF2s4fZnc/8vyhkyX51gtg==

参数说明:open 为是否开启加密,showLog 为是否打印加解密日志,publicKey 为 RSA 公钥,privateKey 为 RSA 私钥,公私钥均为程序生成。

对Controller 里面的API方法进行加密

  1. // 使用 @Encrypt 注解进行加密
  2. @Encrypt
  3. @GetMapping("/testEncrypt")
  4. public Map<String, Object> testEncrypt() {
  5. Map<String, Object> result = new HashMap<>();
  6. result.put("code", 1);
  7. result.put("msg", "success");
  8. return result;
  9. }

加密测试

当 open 为 false 时,不开启加密:

当 open 为 true 时,开启加密: 

"EwLkCMZPR+DEenZ4RLipEjEzvk9c4LdA1dAqzFH0TPuwmxvoyyI6+XY+W/V8WVHT349QyAbYpCTgjDZ49ZUCUFxDdSYG0CQWPpL+5QryVzd6G2E8bwiwGJmFq3DRl64BSjGxlKZ+bOEF4m1b3yEGyTNWiUy7VXg1AgEp03O+Yv4="

 控制台信息:

2021-07-10 10:22:37.345  INFO [service-sign-center,e02b9067dec97e30,e02b9067dec97e30,true] 11872 --- [nio-8011-exec-3] c.s.advice.EncryptResponseBodyAdvice     : Pre-encrypted data:{"msg":"success","code":1},After encryption:EwLkCMZPR+DEenZ4RLipEjEzvk9c4LdA1dAqzFH0TPuwmxvoyyI6+XY+W/V8WVHT349QyAbYpCTgjDZ49ZUCUFxDdSYG0CQWPpL+5QryVzd6G2E8bwiwGJmFq3DRl64BSjGxlKZ+bOEF4m1b3yEGyTNWiUy7VXg1AgEp03O+Yv4=

对传过来的加密参数解密

  1. // 使用 @Decrypt 注解对已加密的参数解密
  2. // 其他java端程序可以用注解,如果是vue,请用RSA密钥解密
  3. @Decrypt
  4. @GetMapping("/testDecrypt")
  5. public Map<String, Object> testDecrypt(@RequestBody Map<String,Object> data) {
  6. return data;
  7. }

解密测试

解密成功:

 控制台信息:

2021-07-10 10:36:23.851  INFO [service-sign-center,0763359838ba829a,0763359838ba829a,true] 6516 --- [nio-8011-exec-5] c.shuibo.advice.DecryptHttpInputMessage  : Encrypted data received:"EwLkCMZPR+DEenZ4RLipEjEzvk9c4LdA1dAqzFH0TPuwmxvoyyI6+XY+W/V8WVHT349QyAbYpCTgjDZ49ZUCUFxDdSYG0CQWPpL+5QryVzd6G2E8bwiwGJmFq3DRl64BSjGxlKZ+bOEF4m1b3yEGyTNWiUy7VXg1AgEp03O+Yv4=",After decryption:{"msg":"success","code":1}

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/小舞很执着/article/detail/853780
推荐阅读
相关标签
  

闽ICP备14008679号