(七、api接口安全设计)莞工校招助手【微服务应用】

news/2024/11/18 12:40:11/

参考 API安全接口安全设计

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

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

参考 API接口安全设计

参考 数据加密之RSA

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

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

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

本项目api接口安全设计

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

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

RSA密钥对生成

KeyPairGenerator生成RSA密钥对

自定义Generator密钥生成器:

package com.admin.utils;import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;import org.apache.tomcat.util.codec.binary.Base64;/*** 描述:密钥生成器*/public class Generator {public static final String ALGORITHM_RSA = "RSA";private static final String RSA_CHARSET = "UTF-8";public static void main(String[] args) throws Exception {KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance(ALGORITHM_RSA);keyPairGen.initialize(1024);// 生成密钥对KeyPair keyPair = keyPairGen.generateKeyPair();// 获取公钥RSAPublicKey rsaPublicKey = (RSAPublicKey) keyPair.getPublic();byte[] keyBs = rsaPublicKey.getEncoded();String publicKey = encodeBase64(keyBs);System.out.println("生成的公钥:\r\n" + publicKey);// 获取私钥RSAPrivateKey rsaPrivateKey = (RSAPrivateKey) keyPair.getPrivate();keyBs = rsaPrivateKey.getEncoded();String privateKey = encodeBase64(keyBs);System.out.println("生成的私钥:\r\n" + privateKey);}/*** 描述:byte数组转String** @param source* @return* @throws Exception*/public static String encodeBase64(byte[] source) throws Exception {return new String(Base64.encodeBase64(source), RSA_CHARSET);}/*** 描述:String转byte数组** @param target* @return* @throws Exception*/public static byte[] decodeBase64(String target) throws Exception {return Base64.decodeBase64(target.getBytes(RSA_CHARSET));}}

测试效果:

生成的公钥:
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCoewDlVLsRQoYulINY2Szh89jWA0J7KB2RPdcMKWpX1WCPcw3wB8q/7bBrJFYr2899V59QcJpd7LRf6hEjLmjaz+7QzDhWFz2x11kqROmy0+PWt+SiSlRkGGWmfQGwUF8waAB/fzaQ5V0naDDF48XbAcvAR7DKOnYBcwpRhEHd3wIDAQAB
生成的私钥:
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

测试类:

package com.jwt.test;import com.alibaba.nacos.common.codec.Base64;
import lombok.extern.slf4j.Slf4j;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.rsa.crypto.KeyStoreKeyFactory;
import org.springframework.test.context.junit4.SpringRunner;import java.security.KeyPair;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;@Slf4j
@SpringBootTest
@RunWith(SpringRunner.class)
public class SecurityUtilTest {@Configurationpublic static class KeyPairConfig {@Beanpublic KeyPair keyPair() {//从classpath下的证书中获取秘钥对KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("jwt.jks"), "yushanma".toCharArray());return keyStoreKeyFactory.getKeyPair("jwt", "yushanma".toCharArray());}}@Autowiredprivate KeyPair keyPair;@Testpublic void test() throws Exception {RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();log.info("公钥信息 => \n {} \n 公钥:{}", publicKey.toString(), encodeBase64(publicKey.getEncoded()));RSAPrivateKey priKey = (RSAPrivateKey) keyPair.getPrivate();log.info("私钥信息 => \n {} \n 私钥:{}", priKey.toString(), encodeBase64(priKey.getEncoded()));}/*** byte数组转String** @param source* @return* @throws Exception*/public static String encodeBase64(byte[] source) throws Exception {return new String(Base64.encodeBase64(source), "UTF-8");}}

测试效果:

2021-06-27 19:16:56.763  INFO [service-oauth2-auth,,,] 9660 --- [           main] com.jwt.test.SecurityUtilTest            : 公钥信息 => Sun RSA public key, 2048 bitsparams: nullmodulus: 20567319899170327840617709116508613419444179747880097355983274966140263504127642335368881422806411882720105980898002775462182893840998598143067464437617108429341174218868746433850709448012734699906126672691529515616716370127079548559157542234879717092569729835437000192116554597443494711257376818024341910971296796534185532013474935580582324311859637511618273147841849708608228263695599394403252262972361043417011840182980810496043340558930295790493636277300954108517771868053504903170991016889800274282430555222393346553171856856567070173388221305727470309637173891604587252568686869543168050575459512799421310277329public exponent: 65537 公钥:MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAouyvwqbhaqIdIU4kRYTv43j04XtmGZywCcdV3I+Zer6hNHRad5hhN69WSEzFlTiXx2/6ktBwtNSM8wRCNjC3RQFK8BNSkaapHN5X7uQeP4TVrsl+/ow4D0Hgc6+GBcAYjKdXdxv4rkuy7s+mQVgCCA2zra4bopAZCKS6uM1f/C1Ki7UTCmTiM7LQdKSQsAio7MjZSKX6T1FAmr7AhjXxxUO80h8YInyboIhc7OyGevhicw7ebciVcz0ATRBrC/9Y4sf4OQV5t+g92vqcVGt2Rhczh8gQnEETVipAlH0W/syF0Xu2zr6HBtOxGP30Szg9jAytX4cL3G3WCwv47+WW0QIDAQAB
2021-06-27 19:16:56.766  INFO [service-oauth2-auth,,,] 9660 --- [           main] com.jwt.test.SecurityUtilTest            : 私钥信息 => SunRsaSign RSA private CRT key, 2048 bitsparams: nullmodulus: 20567319899170327840617709116508613419444179747880097355983274966140263504127642335368881422806411882720105980898002775462182893840998598143067464437617108429341174218868746433850709448012734699906126672691529515616716370127079548559157542234879717092569729835437000192116554597443494711257376818024341910971296796534185532013474935580582324311859637511618273147841849708608228263695599394403252262972361043417011840182980810496043340558930295790493636277300954108517771868053504903170991016889800274282430555222393346553171856856567070173388221305727470309637173891604587252568686869543168050575459512799421310277329private exponent: 19303536124600864633358183739817886254024619623908704828822363598689100937468777637800630122172549779607148921754675232596531269361731903684637376406576870157144447048272221693793691169068820840002225485409096853770911453476916292046840417212680644496451837621156444173781059146569866788065386698760658421102988458584474980507935585707721550065745072389564711878488207910700168001180802928703831440170322346912658552971247917296991274641100029517759307731419438082221755651445909904889771910842153508992367099498241878354429306538249813955752652096678699781581144570632109930711682397263860234110176066187681387955073 私钥: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

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

依赖:

     <dependencies><!--        springboot-web--><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><!--nacos客户端--><dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId></dependency><!--fegin组件--><dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-openfeign</artifactId></dependency><!-- Feign Client for loadBalancing --><dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-loadbalancer</artifactId></dependency><!--        注解--><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><version>1.18.20</version><scope>provided</scope></dependency><!--        容错组件sentinel--><dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-sentinel</artifactId></dependency></dependencies>

将密钥证书放到resource目录:

主类:

package com.sign;import org.springframework.boot.SpringApplication;
import org.springframework.cloud.client.SpringCloudApplication;@SpringCloudApplication
public class SignApplication {public static void main(String[] args) {SpringApplication.run(SignApplication.class);}
}

封装的安全工具类:

package com.sign.util;import org.apache.tomcat.util.codec.binary.Base64;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.rsa.crypto.KeyStoreKeyFactory;import javax.crypto.Cipher;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;/*** 描述:安全工具类*/public final class SecurityUtil {private static final String ALGORITHM_RSA = "RSA";private static final String RSA_CHARSET = "UTF-8";/*** 描述:将字符串通过RSA算法公钥加密** @param content 需要加密的内容* @param pubKey  公钥* @return 加密后字符串* @throws Exception*/private static String EncryptByRSAPubKey(String content, String pubKey) throws Exception {try {PublicKey publicKey = SecurityUtil.getRSAPubKey(pubKey);Cipher cipher = Cipher.getInstance(publicKey.getAlgorithm());cipher.init(Cipher.ENCRYPT_MODE, publicKey);cipher.update(content.getBytes(RSA_CHARSET));return SecurityUtil.encodeBase64(cipher.doFinal());} catch (Exception e) {e.printStackTrace();throw new Exception();}}/*** 描述:将字符串通过RSA算法公钥解密** @param content 需要解密的内容* @param pubKey  公钥* @return 解密后字符串* @throws Exception*/public static String DecryptByRSAPubKey(String content, String pubKey) throws Exception {try {PublicKey publicKey = SecurityUtil.getRSAPubKey(pubKey);Cipher cipher = Cipher.getInstance(publicKey.getAlgorithm());cipher.init(Cipher.DECRYPT_MODE, publicKey);cipher.update(SecurityUtil.decodeBase64(content));return new String(cipher.doFinal(), RSA_CHARSET);} catch (Exception e) {e.printStackTrace();throw new Exception();}}/*** 描述:将字符串通过RSA算法私钥加密** @param content 需要加密的内容* @param priKey  私钥* @return 加密后字符串* @throws Exception*/public static String EncryptByRSAPriKey(String content, String priKey) throws Exception {try {PrivateKey privateKey = SecurityUtil.getRSAPriKey(priKey);Cipher cipher = Cipher.getInstance(privateKey.getAlgorithm());cipher.init(Cipher.ENCRYPT_MODE, privateKey);cipher.update(content.getBytes(RSA_CHARSET));return SecurityUtil.encodeBase64(cipher.doFinal());} catch (Exception e) {e.printStackTrace();throw new Exception();}}/*** 描述:将字符串通过RSA算法私钥解密** @param content 需要解密的内容* @param priKey  私钥* @return 解密后字符串* @throws Exception*/public static String DecryptByRSAPriKey(String content, String priKey) throws Exception {try {PrivateKey privateKey = SecurityUtil.getRSAPriKey(priKey);Cipher cipher = Cipher.getInstance(privateKey.getAlgorithm());cipher.init(Cipher.DECRYPT_MODE, privateKey);cipher.update(SecurityUtil.decodeBase64(content));return new String(cipher.doFinal(), RSA_CHARSET);} catch (Exception e) {e.printStackTrace();throw new Exception();}}/*** 获取密钥对** @return*/private static KeyPair getKeyPair() {//从classpath下的证书中获取秘钥对KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("jwt.jks"), "yushanma".toCharArray());return keyStoreKeyFactory.getKeyPair("jwt", "yushanma".toCharArray());}/*** 获取公钥字符串** @return* @throws Exception*/public static String getPublicKey() throws Exception {// 获取密钥对KeyPair keyPair = getKeyPair();// 获取私钥信息PublicKey publicKey = keyPair.getPublic();// byte 转 Stringreturn encodeBase64(publicKey.getEncoded());}/*** 获取私钥字符串** @return* @throws Exception*/public static String getPrivateKey() throws Exception {// 获取密钥对KeyPair keyPair = SecurityUtil.getKeyPair();// 获取私钥信息PrivateKey privateKey = keyPair.getPrivate();// byte 转 Stringreturn SecurityUtil.encodeBase64(privateKey.getEncoded());}/*** 描述:获取RSA公钥** @return PublicKey* @throws Exception*/private static PublicKey getRSAPubKey(String pubKey) throws Exception {try {X509EncodedKeySpec publicKeySpec = new X509EncodedKeySpec(SecurityUtil.decodeBase64(pubKey));KeyFactory keyFactory = KeyFactory.getInstance(ALGORITHM_RSA);return keyFactory.generatePublic(publicKeySpec);} catch (Exception e) {e.printStackTrace();throw new Exception();}}/*** 描述:获取RSA私钥** @param priKey 私钥* @return PrivateKey* @throws Exception*/private static PrivateKey getRSAPriKey(String priKey) throws Exception {try {PKCS8EncodedKeySpec privateKeySpec = new PKCS8EncodedKeySpec(SecurityUtil.decodeBase64(priKey));KeyFactory keyFactory = KeyFactory.getInstance(ALGORITHM_RSA);return keyFactory.generatePrivate(privateKeySpec);} catch (Exception e) {e.printStackTrace();throw new Exception();}}/*** base64编码** @param source* @return* @throws Exception*/public static String encodeBase64(byte[] source) throws Exception {return new String(Base64.encodeBase64(source), RSA_CHARSET);}/*** Base64解码** @param target* @return* @throws Exception*/public static byte[] decodeBase64(String target) throws Exception {return Base64.decodeBase64(target.getBytes(RSA_CHARSET));}public static void main(String[] args) throws Exception {String pubKey = getPublicKey();String priKey = getPrivateKey();String content = "age=18&name=yushanma";String s = EncryptByRSAPubKey(content, pubKey);System.out.println("公钥加密后:" + s);System.out.println("私钥解密后:" + DecryptByRSAPriKey(s, priKey));content = "age=18&name=yushanma";s = EncryptByRSAPriKey(content, priKey);System.out.println("私钥加密后:" + s);System.out.println("公钥解密后:" + DecryptByRSAPubKey(s, pubKey));}
}

测试:

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

封装签名工具类:

package com.sign.util;import com.alibaba.csp.sentinel.util.StringUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.digest.DigestUtils;import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.util.*;@Slf4j
public class ApiUtil {/*** 参数排序** @param data* @return*/private static String getSortedContent(Map<String, String> data) {StringBuffer content = new StringBuffer();List<String> keys = new ArrayList<>(data.keySet());Collections.sort(keys);int index = 0;for (String key : keys) {String value = data.get(key);content.append((index == 0 ? "" : "&")).append(key).append("=").append(value);index++;}return content.toString();}/*** 参数加密** @param data* @return* @throws Exception*/public static String getSignature(Map<String, String> data) throws Exception {// 对排序好的字符串进行加密,然后转大写String summary = DigestUtils.md5Hex(getSortedContent(data)).toLowerCase();log.info("md5 summary:" + summary);// 对summary进行私钥加密return URLEncoder.encode(SecurityUtil.EncryptByRSAPriKey(summary, SecurityUtil.getPrivateKey()), "utf-8");}/*** 验证签名** @param params* @return*/public static boolean verifySign(Map<String, String> params, String sign, String signType) throws UnsupportedEncodingException {if (StringUtil.isEmpty(URLDecoder.decode(sign, "utf-8"))) {return false;}//暂不支持非RSA的签名if (StringUtil.isEmpty(signType) || !"RSA".equals(signType)) {return false;}//参与签名的数据String data = getSortedContent(params);log.info("sign data:" + data);String summary = DigestUtils.md5Hex(data).toLowerCase();log.info("sign summary:" + summary);String summaryDecode = null;try {summaryDecode = SecurityUtil.DecryptByRSAPubKey(URLDecoder.decode(sign, "utf-8"), SecurityUtil.getPublicKey());} catch (Exception e) {throw new RuntimeException("do_digest_error", e);}return summary.equals(summaryDecode);}public static void main(String[] args) throws Exception {Map<String,String> data = new HashMap<>();data.put("name","yushanma");data.put("age","20");String sign = getSignature(data);log.info("参数签名:{}",sign);String signType = "RSA";log.info("验证结果:{}",verifySign(data,sign,signType));}
}

测试:

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

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

package com.sign.controller;import com.sign.util.ApiUtil;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;import javax.servlet.http.HttpServletRequest;
import java.io.UnsupportedEncodingException;
import java.util.HashMap;
import java.util.Map;@RestController
public class SignController {/*** 模拟前端参数签名* @param data* @return* @throws Exception*/@GetMapping("/sign/getSign")public Map<String, Object> getSign(@RequestParam Map<String, String> data) throws Exception {// 返回信息Map<String, Object> result = new HashMap<>();result.put("code", 0);// 状态码result.put("msg", "success");// 信息String sign = ApiUtil.getSignature(data);result.put("data", sign);// 数据result.put("verify",ApiUtil.verifySign(data,sign,"RSA"));return result;}/*** 模拟后端校验签名* @param request* @param data* @return* @throws UnsupportedEncodingException*/@GetMapping("/sign/verifySign")public boolean verifySign(HttpServletRequest request, @RequestParam Map<String, String> data) throws UnsupportedEncodingException {String sign = request.getHeader("sign");String signType = request.getHeader("sign_type");return ApiUtil.verifySign(data, sign, signType);}}

yml相关配置:

server:port: 8011
spring:application:name: service-sign-centercloud:nacos:discovery:server-addr: localhost:8848sentinel:transport:port: 9999dashboard: localhost:8080

模拟前端签名测试:

模拟后端签名校验测试:

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

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

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

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

加入token防止表单重复提交

模拟重复提交表单

测试控制器

模拟高并发测试接口:

import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;@Slf4j
@RestController
public class TestController {/*** 模拟高并发重复提交* @param data* @return*/@PostMapping("/form/repeatSubmitTest")public Map<String, Object> repeatSubmitTest(@RequestParam Map<String, String> data) {// 模拟提交表单信息Map<String, Object> result = new HashMap<>();result.put("code", 0);// 状态码result.put("msg", "success");// 信息log.info("提交表单[]");return result;}
}

jmeter测试组

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

运行测试

表单被重复提交:

解决表单重复提交思路

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

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

加入依赖

redis依赖:

<!--        redis 依赖-->
<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-data-redis -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId><exclusions><exclusion><groupId>io.lettuce</groupId><artifactId>lettuce-core</artifactId></exclusion></exclusions>
</dependency>
<dependency><groupId>redis.clients</groupId><artifactId>jedis</artifactId>
</dependency>

封装的 Redis 操作类

package com.sign.util;import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;import java.util.concurrent.TimeUnit;@Component
public class RedisTemplateUtil {@Autowiredprivate StringRedisTemplate stringRedisTemplate;/*** 设置 String 对象* @param key* @param data* @param timeout* @return*/public Boolean setString(String key, Object data, Long timeout) {if (data instanceof String) {if (null != timeout) {stringRedisTemplate.opsForValue().set(key, (String) data, timeout, TimeUnit.SECONDS);} else {stringRedisTemplate.opsForValue().set(key, (String) data);}return true;} else {return false;}}/*** 获取 String 对象* @param key* @return*/public Object getString(String key) {return stringRedisTemplate.opsForValue().get(key);}/*** 删除某个 key* @param key*/public void delKey(String key) {stringRedisTemplate.delete(key);}}

token 工具类

package com.sign.util;import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;import java.util.Objects;
import java.util.UUID;@Component
public class TokenUtil {@Autowiredprivate RedisTemplateUtil redisTemplateUtil;// 时间为 秒L ,如 30分钟 应为 60*30L ,这里设置 1分钟private static final Long TIMEOUT = 60L;/*** 生成 token* @return*/public String getToken() {StringBuilder token = new StringBuilder("token_");token.append(UUID.randomUUID().toString().replaceAll("-", ""));redisTemplateUtil.setString(token.toString(), token.toString(), TIMEOUT);return token.toString();}/*** 判断是否有 token* @param tokenKey* @return*/public Boolean findToken(String tokenKey) {if (Objects.nonNull(redisTemplateUtil.getString(tokenKey))) {String token = redisTemplateUtil.getString(tokenKey).toString();return !StringUtils.isEmpty(token);}return false;}/*** 删除某个 key* @param key*/public void deleteKey(String key) {redisTemplateUtil.delKey(key);}}

测试控制器

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

package com.sign.controller;import com.sign.util.TokenUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;import javax.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.Map;@Slf4j
@RestController
public class tokenController {@Autowiredprivate TokenUtil tokenUtil;/*** 获取 token* @return*/@GetMapping("/getToken")public String getToken(){return tokenUtil.getToken();}/*** 模拟高并发重复提交,根据 token 缓存防止重复提交* @param data* @return*/@PostMapping("/form/repeatSubmitTest")public Map<String, Object> repeatSubmitTest(HttpServletRequest request, @RequestParam Map<String, String> data) {// 返回信息Map<String, Object> result = new HashMap<>();// 检查 header 中的 tokenString token = request.getHeader("token");if(!StringUtils.isEmpty(token)) {if (tokenUtil.findToken(token)) {// 模拟提交表单信息// TODO Somethingresult.put("code", 0);// 状态码result.put("msg", "success");// 信息log.info("提交表单[]");// 删除缓存中的tokentokenUtil.deleteKey(token);} else {log.info("请勿重复提交[]");result.put("code", -1);// 状态码result.put("msg", "fail");// 信息}}else{log.info("表单无token[]");result.put("code", -1);// 状态码result.put("msg", "fail");// 信息}return result;}
}

测试

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

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

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

使用注解与aop拦截实现

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

参数签名校验

封装请求常量类

package com.sign.constant;/*** 请求参数常量*/
public class ReqParameterConstant {// 头部public static final String HEAD = "head";// 表单public static final String FORM = "form";
}

自定义签名校验注解

package com.sign.annotation;import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;/*** 验证参数签名*/
@Target(value = ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface VerifySign {String type();
}

aop拦截器

package com.sign.component;import com.sign.annotation.VerifySign;
import com.sign.constant.ReqParameterConstant;
import com.sign.util.ApiUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.PrintWriter;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Map;/*** 先定义一个切面,myPointCut,这个切面主要针对 controller 层的方法。* 环绕通知:aroundAOP,主要用于判断签名是否有效。*/
@Slf4j
@Aspect
@Component
public class VerifySignAop {@Autowiredprivate ApiUtil apiUtil;/*** 定义切入面:使用 AOP 环绕通知拦截所有访问,拦截的是 controller 层*/@Pointcut("execution(public * com.sign.controller.*.*(..))")public void myPointCut() {}/*** 环绕通知,主要用于验证签名的方法上** @param joinPoint* @return* @throws Throwable*/@Around("myPointCut()")public Object aroundAOP(ProceedingJoinPoint joinPoint) throws Throwable {// 判断方法上是否有注解 @VerifySignMethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();VerifySign verifySign = methodSignature.getMethod().getDeclaredAnnotation(VerifySign.class);String sign = null;String signType = null;// 如果方法上有此注解,则拦截if (null != verifySign) {// 获取上下文的请求HttpServletRequest request = getRequest();String type = verifySign.type();log.info("type[] " + type);if (type.equals(ReqParameterConstant.HEAD)) {sign = request.getHeader("sign");signType = request.getHeader("sign_type");} else if (type.equals(ReqParameterConstant.FORM)) {sign = request.getParameter("sign");signType = request.getParameter("sign_type");}if (StringUtils.isBlank(sign)) {response("该请求无签名!");return null;}if (StringUtils.isBlank(signType) || !"RSA".equals(signType)) {response("无签名加密方式或签名加密方式不支持!");return null;}log.info("sign[] " + sign);// 获取请求的所有参数Map<String, String> data = new HashMap();Enumeration<String> parameterNames = request.getParameterNames();while (parameterNames.hasMoreElements()) {String key = parameterNames.nextElement();data.put(key, request.getParameter(key));}boolean verifyFlag = apiUtil.verifySign(data, sign, signType);if (!verifyFlag) {response("无效的签名!");return null;} else {log.info("有效的签名。");}}// 继续往后执行Object proceed = joinPoint.proceed();return proceed;}/*** 获取容器上下文请求** @return*/public HttpServletRequest getRequest() {ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();HttpServletRequest request = attributes.getRequest();return request;}/*** 相应错误响应信息** @param msg*/private void response(String msg) {ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();HttpServletResponse response = attributes.getResponse();response.setHeader("Content-type", "text/html;charset=UTF-8");try (PrintWriter writer = response.getWriter()) {writer.println(msg);} catch (Exception e) {e.printStackTrace();}}}

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

    @VerifySign(type = ReqParameterConstant.HEAD)@GetMapping("sign/test")public String test(@RequestParam Map<String,String> data){// 模拟业务逻辑// TODO Somethingreturn "test";}

测试

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

参数被修改时:签名无效

token校验

自定义注解

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

aop拦截器

package com.sign.component;import com.sign.annotation.ExistsApiToken;
import com.sign.constant.ReqParameterConstant;
import com.sign.util.TokenUtil;
import lombok.Synchronized;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.PrintWriter;/*** 先定义一个切面,myPointCut,这个切面主要针对 controller 层的方法。* <p>* 前置通知:before,主要用于获取 token。* <p>* 环绕通知:aroundAOP,主要用于判断 token 是否重复。*/
@Aspect
@Component
@Slf4j
public class ExtApiAop {@Autowiredprivate TokenUtil tokenUtil;/*** 定义切入面:使用 AOP 环绕通知拦截所有访问,拦截的是 controller 层*/@Pointcut("execution(public * com.sign.controller.*.*(..))")public void myPointCut() {}/*** 环绕通知,主要用于业务逻辑的方法上** @param joinPoint* @return* @throws Throwable*/@Around("myPointCut()")@Synchronizedpublic Object aroundAOP(ProceedingJoinPoint joinPoint) throws Throwable {// 判断方法上是否有注解 @ExistsApiTokenMethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();ExistsApiToken extApiToken = methodSignature.getMethod().getDeclaredAnnotation(ExistsApiToken.class);String token = null;// 如果方法上有此注解,则拦截if (null != extApiToken) {// 获取上下文的请求HttpServletRequest request = getRequest();String type = extApiToken.type();log.info("type[] " + type);if (type.equals(ReqParameterConstant.HEAD)) {token = request.getHeader("token");} else if (type.equals(ReqParameterConstant.FORM)) {token = request.getParameter("token");}if (StringUtils.isBlank(token)) {response("请求失效,请勿重复提交!");return null;}log.info("token[] " + token);// 判断缓存里是否有该 tokenBoolean existsFlag = tokenUtil.findToken(token);log.info("existsFlag[] " + existsFlag);if (!existsFlag) {response("请求失效,请勿重复提交!");log.info("请求失效,请勿重复提交!");return null;} else {// 删除 tokentokenUtil.deleteKey(token);log.info("请求有效,删除token[]");}}// 继续往后执行Object proceed = joinPoint.proceed();return proceed;}/*** 获取容器上下文请求** @return*/public HttpServletRequest getRequest() {ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();HttpServletRequest request = attributes.getRequest();return request;}/*** 相应错误响应信息** @param msg*/private void response(String msg) {ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();HttpServletResponse response = attributes.getResponse();response.setHeader("Content-type", "text/html;charset=UTF-8");try (PrintWriter writer = response.getWriter()) {writer.println(msg);} catch (Exception e) {e.printStackTrace();}}}

控制器添加token校验注解

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

测试

模拟前端获取token:

header加入表单token:

高并发测试:

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

线程安全问题

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

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

同时使用这两个注解

模拟业务接口

    @ExistsApiToken(type = ReqParameterConstant.HEAD)@VerifySign(type = ReqParameterConstant.HEAD)@GetMapping("/test")public String test(@RequestParam Map<String,String> data){// 模拟业务逻辑// TODO Somethingreturn "test";}

正常情况测试

运行正常:

重复提交测试

重复的请求失效:

修改参数

签名失效:

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

时间戳超时机制

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

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

模拟时间戳超时机制

package com.sign.controller;import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.HashMap;
import java.util.Map;/*** @Author: 马建生* @Date: 2021/07/02/10:20* @Description: 模拟时间戳超时机制,防御表单重复提交和数据爬取。* 使用的时区都是东8区(北京时间),这是为了防止服务器设置时区错误时导致时间不对。*/
@RestController
@Slf4j
public class TimestampController {/*** 模拟前端获取客户端当前时间,与服务端获取的时间做差异对齐,最后返回对齐后的值* 为了模拟真实情况中时钟可能不一致,这里做一个微小的时钟差异* 时间戳采用毫秒级别,客户端时间为A,服务端时间为B,差异为 A - B = AB* 每次请求附带的时间戳为 A - AB** @return*/@GetMapping("/getClientTimestamp")public Long getClientTimestamp() {// 获取客户端的时间戳// 获取秒数,比当前时间快一分钟
//        Long second = LocalDateTime.now().plusMinutes(1).toEpochSecond(ZoneOffset.of("+8"));
//        log.info("timestamp_second[] " + second);// 获取毫秒数,慢一分钟Long milliSecond = LocalDateTime.now().minusMinutes(1).toInstant(ZoneOffset.of("+8")).toEpochMilli();log.info("timestamp_milliSecond[] " + milliSecond);// 模拟差异比较,客户端时间为A,服务端时间为B,差异为 A - B = ABLong difference = milliSecond - getServerTimestamp();// 模拟时钟对齐,每次请求附带的应为该时间戳return milliSecond - difference;}/*** 模拟前端获取服务器当前时间** @return*/@GetMapping("/getServerTimestamp")public Long getServerTimestamp() {// 获取秒数Long second = LocalDateTime.now().toEpochSecond(ZoneOffset.of("+8"));log.info("timestamp_second[] " + second);// 获取毫秒数Long milliSecond = LocalDateTime.now().toInstant(ZoneOffset.of("+8")).toEpochMilli();log.info("timestamp_milliSecond[] " + milliSecond);return milliSecond;}/*** 模拟业务逻辑,先检查时间戳差异是否在可控范围,若通过则执行业务逻辑,否则返回超时信息* 为了方便,这里模拟超时 10s* @param data* @return*/@GetMapping("/test")public Map<String, Object> test(@RequestParam Map<String, String> data) {// 获取参数中的时间戳Long timestamp = Long.parseLong(data.get("timestamp"));// 返回信息Map<String,Object> result = new HashMap<>();if (getServerTimestamp() - timestamp <= 10000 && getServerTimestamp() - timestamp >= 0) {// 模拟业务逻辑// TODO Somethingresult.put("code",0);result.put("msg","success");}else{result.put("code",-1);result.put("msg","请求超时,请重新提交!");}return result;}
}

测试

对齐时钟

带时间戳请求

超时请求

以注解结合aop方式实现

自定义注解:

package com.sign.annotation;import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;/*** @Author: 马建生* @Date: 2021/07/02/11:19* @Description: 检查请求的时间戳是否超时*/@Target(value = ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CheckTimestamp {String type();
}

aop拦截器:

package com.sign.component;import com.sign.annotation.CheckTimestamp;
import com.sign.constant.ReqParameterConstant;
import lombok.Synchronized;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.PrintWriter;
import java.time.LocalDateTime;
import java.time.ZoneOffset;/*** @Author: 马建生* @Date: 2021/07/02/11:21* @Description: * 先定义一个切面,myPointCut,这个切面主要针对 controller 层的方法。* 环绕通知:aroundAOP,主要用于判断 token 是否重复。* */
@Aspect
@Component
@Slf4j
public class CheckTimestampAop {/*** 定义切入面:使用 AOP 环绕通知拦截所有访问,拦截的是 controller 层*/@Pointcut("execution(public * com.sign.controller.*.*(..))")public void myPointCut() {}/*** 环绕通知,主要用于业务逻辑的方法上** @param joinPoint* @return* @throws Throwable*/@Around("myPointCut()")@Synchronizedpublic Object aroundAOP(ProceedingJoinPoint joinPoint) throws Throwable {// 判断方法上是否有注解 @CheckTimestampMethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();CheckTimestamp extApiToken = methodSignature.getMethod().getDeclaredAnnotation(CheckTimestamp.class);long timestamp = 0;// 如果方法上有此注解,则拦截if (null != extApiToken) {// 获取上下文的请求HttpServletRequest request = getRequest();String type = extApiToken.type();log.info("type[] " + type);if (type.equals(ReqParameterConstant.HEAD)) {timestamp = Long.parseLong(request.getHeader("timestamp"));} else if (type.equals(ReqParameterConstant.FORM)) {timestamp = Long.parseLong(request.getParameter("timestamp"));}if (timestamp == 0) {response("请求无效,请重新提交!");return null;}long serverTimestamp = LocalDateTime.now().toInstant(ZoneOffset.of("+8")).toEpochMilli();// 判断超时if (serverTimestamp - timestamp <= 10000 && serverTimestamp - timestamp >= 0) {log.info("请求有效。");} else {response("请求超时,请重新提交!");return null;}}// 继续往后执行Object proceed = joinPoint.proceed();return proceed;}/*** 获取容器上下文请求** @return*/public HttpServletRequest getRequest() {ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();HttpServletRequest request = attributes.getRequest();return request;}/*** 相应错误响应信息** @param msg*/private void response(String msg) {ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();HttpServletResponse response = attributes.getResponse();response.setHeader("Content-type", "text/html;charset=UTF-8");try (PrintWriter writer = response.getWriter()) {writer.println(msg);} catch (Exception e) {e.printStackTrace();}}
}

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

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

测试:

带时间戳请求:

超时请求:

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,这里为方便未作封装

package com.oauth2.controller;import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.convert.Convert;
import com.oauth2.api.CommonResult;
import com.oauth2.constant.RedisConstant;
import com.oauth2.domain.dto.Oauth2TokenDto;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.endpoint.TokenEndpoint;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.annotation.*;import javax.annotation.Resource;
import java.security.Principal;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;@Slf4j
@RestController
public class AuthController {@Autowiredprivate TokenEndpoint tokenEndpoint;@Resourceprivate RedisTemplate<String, Object> redisTemplate;/*** Oauth2登录认证*/@PostMapping("/oauth/token")public CommonResult<Oauth2TokenDto> postAccessToken(Principal principal, @RequestParam Map<String, String> parameters) throws HttpRequestMethodNotSupportedException {OAuth2AccessToken oAuth2AccessToken = tokenEndpoint.postAccessToken(principal, parameters).getBody();Oauth2TokenDto oauth2TokenDto = Oauth2TokenDto.builder().token(oAuth2AccessToken.getValue()).refreshToken(oAuth2AccessToken.getRefreshToken().getValue()).expiresIn(oAuth2AccessToken.getExpiresIn()).tokenHead("Bearer ").build();String username = parameters.get("username");String token = oauth2TokenDto.getToken();// 将 token => username 作为键值对存入redis缓存,并设定有效时间为24小时// 生产环境时可以将 token 有效期设置长一点,以免 redis 数据频繁变动redisTemplate.opsForValue().set(token, username, 3600 * 24, TimeUnit.SECONDS);// 判断用户是否首次登录if (!redisTemplate.opsForHash().hasKey(RedisConstant.USERID_TOKEN_MAP, username)) {log.info("user log[] 首次登录");// 将 username 与 list<token> 作为键值对缓存在 redis 中redisTemplate.opsForHash().put(RedisConstant.USERID_TOKEN_MAP, username, CollUtil.toList(token));}else{// 先将之前的 token 拿出来Object object = redisTemplate.opsForHash().get(RedisConstant.USERID_TOKEN_MAP, username);List<String> userToken = Convert.toList(String.class, object);log.info("user log[] 非首次登录 tokenList => {}",userToken.toString());// 删除已有的 tokenredisTemplate.opsForHash().delete(RedisConstant.USERID_TOKEN_MAP,username);// 添加新增的 tokenuserToken.add(token);// 将 username 与 list<token> 作为键值对缓存在 redis 中redisTemplate.opsForHash().put(RedisConstant.USERID_TOKEN_MAP, username, userToken);}return CommonResult.success(oauth2TokenDto);}
}

修改gateway模块的AuthGlobalFilter

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

package com.gateway.filter;import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSONObject;
import com.nimbusds.jose.JWSObject;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.LoggerFactory;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;import javax.annotation.Resource;
import java.nio.charset.StandardCharsets;
import java.text.ParseException;
//import java.util.logging.Logger;@Slf4j
@Component
public class AuthGlobalFilter implements GlobalFilter, Ordered {//    private static Logger LOGGER = (Logger) LoggerFactory.getLogger(AuthGlobalFilter.class);@Resourceprivate RedisTemplate<String, Object> redisTemplate;@Overridepublic Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {String token = exchange.getRequest().getHeaders().getFirst("Authorization");if (StrUtil.isEmpty(token)) {return chain.filter(exchange);}try {// 从token中解析用户信息并设置到Header中去String realToken = token.replace("Bearer ", "");// 检查token是否在redis缓存中Object object = redisTemplate.opsForValue().get(realToken);if (object != null) {JWSObject jwsObject = JWSObject.parse(realToken);String userStr = jwsObject.getPayload().toString();log.info("AuthGlobalFilter.filter() user:{}", userStr);
//            LOGGER.info(String.format("AuthGlobalFilter.filter() user:{0}",userStr));// 将json格式的用户信息转成json对象JSONObject user = JSONObject.parseObject(userStr);// 将用户名加到header中,控制器通过HttpServletRequest的getHeader方法获取usernameServerHttpRequest request = exchange.getRequest().mutate().header("username", user.getString("user_name")).build();exchange = exchange.mutate().request(request).build();} else {// 设置status和bodyServerWebExchange finalExchange = exchange;return Mono.defer(() -> {finalExchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);//设置statusfinal ServerHttpResponse response = finalExchange.getResponse();// token无效,返回401,这里可以用json对象实现byte[] bytes = "{\"code\":\"401\",\"data\":\"Access Denied\",\"message\":\"暂未登录或token已经过期\"}".getBytes(StandardCharsets.UTF_8);DataBuffer buffer = finalExchange.getResponse().bufferFactory().wrap(bytes);log.info("token不在redis缓存中,请求无效");return response.writeWith(Flux.just(buffer));//设置body});}} catch (ParseException e) {e.printStackTrace();}return chain.filter(exchange);}@Overridepublic int getOrder() {return 0;}
}

student模块 修改密码changePassword 接口

/*** 修改密码接口* 需要旧密码 oldPwd 验证,旧密码通过则更新新密码 newPwd 到数据库* 撤销 redis 缓存该用户的所有有效 token** @param request* @param oldPwd* @param newPwd* @return*/
@ApiOperation(value = "更新学生账号密码",notes = "需要在旧密码的校验,新密码二次校验需在前端完成"
)
@GlobalTransactional
@GetMapping("/changePassword")
public Map<String, Object> changePassword(HttpServletRequest request, @RequestParam("oldPwd") String oldPwd, @RequestParam("newPwd") String newPwd) throws Exception {// 从 header 获取 usernameString username = request.getHeader("username");Student student = new Student();student.setUsername(username);// 先查询该学生信息QueryWrapper<Student> studentQueryWrapper = new QueryWrapper<>(student);Student studentResult = studentMapper.selectOne(studentQueryWrapper);log.info("student => {}", studentResult.toString());// 返回信息Map<String, Object> result = new HashMap<>();// 如果旧密码匹配,则更新密码,销毁 redis 中该 user 所有有效的 token// 第一个参数为明文,第二个参数为密文if (passwordEncoder.matches(oldPwd, studentResult.getPassword())) {student.setId(studentResult.getId());// 密码需要加密student.setPassword(passwordEncoder.encode(newPwd));// 通过 id 更新if (studentMapper.updateById(student) == 0) {throw new Exception(MySQLMessageConstant.UPDATE_FAIL);}// 销毁所有有效 tokenObject object = redisTemplate.opsForHash().get(RedisConstant.USERID_TOKEN_MAP, username);List<String> userToken = Convert.toList(String.class, object);redisTemplate.opsForHash().delete(RedisConstant.USERID_TOKEN_MAP, username);userToken.forEach(p -> redisTemplate.delete(p));result.put("code", 0);// 状态码result.put("msg", "success");// 信息} else {result.put("code", -1);// 状态码result.put("msg", "旧密码错误!");// 信息}return result;
}

测试

获取token:

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

访问信息接口:

{"msg": "success","code": 0,"data": {"openid": null,"roles": null,"updateTime": null,"sid": 11819,"password": null,"regTime": null,"loginTime": "2021-07-05T05:41:14","major": null,"sno": "pG1kj6V9zeu6GFcosJ2sEg==","nickname": "sam","id": 1407617878270382081,"status": true,"username": "1801561529@qq.com"}
}

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

token过期时重新请求:

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

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

修改密码测试:

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

旧密码正确则修改成功。

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

使用 rsa-encrypt-body-spring-boot

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

引入该依赖

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

 启动类 Application中 添加 @EnableSecurity 注解

package com.sign;import cn.shuibo.annotation.EnableSecurity;
import org.springframework.boot.SpringApplication;
import org.springframework.cloud.client.SpringCloudApplication;@EnableSecurity
@SpringCloudApplication
public class SignApplication {public static void main(String[] args) {SpringApplication.run(SignApplication.class);}
}

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

生成的公钥:
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCoewDlVLsRQoYulINY2Szh89jWA0J7KB2RPdcMKWpX1WCPcw3wB8q/7bBrJFYr2899V59QcJpd7LRf6hEjLmjaz+7QzDhWFz2x11kqROmy0+PWt+SiSlRkGGWmfQGwUF8waAB/fzaQ5V0naDDF48XbAcvAR7DKOnYBcwpRhEHd3wIDAQAB
生成的私钥:
MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAKh7AOVUuxFChi6Ug1jZLOHz2NYDQnsoHZE91wwpalfVYI9zDfAHyr/tsGskVivbz31Xn1Bwml3stF/qESMuaNrP7tDMOFYXPbHXWSpE6bLT49a35KJKVGQYZaZ9AbBQXzBoAH9/NpDlXSdoMMXjxdsBy8BHsMo6dgFzClGEQd3fAgMBAAECgYAXH8LQtx9x0AKgtAuPD0fEv3Y8cXgXdTsRqz4v0iNhaMz3A2CfWEJws0vqeLNHE8VXu8YHAV1+lLVxEKxHeuAzJjJlekGSTZAkfgF4Xm5mdfGuhVRF4ldHHbguQ5oT+OhdsxC3mMAUIwSjAkhuqEU/tK3quZeKt4Z/V3s2qa4fiQJBAPjtQSjHmnhEHayIaIiyWm/Nu+d3plq3tZsu1Mz8yj4oTPQ/rt4MvcMbI2G/tSXh1fzWmleQDlc1sATP27lzdn0CQQCtRJDX+gJejoJ9PMf/0Nzrqr1LH64UaBL0MrFc58vGN0UHtMkwKC1g58R5x006b3m8pp1mUNKutR9tMB/80SiLAkEArqECzTD6VNS0XI11iDBW8YhLAh8WPR4T8UHxV70fxGtRUSg77NrTZURsle5/jovYKwACVttgtB2d1kJbysYNoQJAZ/coYi+llE82hScfaqRMqyv8AUO1FJGOLfDs864yW3F2fjVAMyEoeWkYP2oTMOkKxuPCtk3w3NvZS48A4pYuGQJALU19SKEWk+JGC8tIOdO/tE2rSp/Uc+S/trbOkmSD0vA8sCetJ4jth68eNf60csGXDF2s4fZnc/8vyhkyX51gtg==
server:port: 8011
spring:redis:host: localhostport: 6379database: 0application:name: service-sign-centercloud:nacos:discovery:server-addr: localhost:8848sentinel:transport:port: 9999dashboard: localhost:8080# api 加密
rsa:encrypt:open: true # 是否开启加密 true  or  falseshowLog: true # 是否打印加解密log true  or  falsepublicKey: MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCoewDlVLsRQoYulINY2Szh89jWA0J7KB2RPdcMKWpX1WCPcw3wB8q/7bBrJFYr2899V59QcJpd7LRf6hEjLmjaz+7QzDhWFz2x11kqROmy0+PWt+SiSlRkGGWmfQGwUF8waAB/fzaQ5V0naDDF48XbAcvAR7DKOnYBcwpRhEHd3wIDAQABprivateKey: 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方法进行加密

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

加密测试

当 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=

对传过来的加密参数解密

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

解密测试

解密成功:

 控制台信息:

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}


http://www.ppmy.cn/news/508407.html

相关文章

安卓版App开发心得

从2016年4月到6月主要做的工作是网站的开发&#xff0c;而6月到现在2016年8月初&#xff0c;主要做的工作是Android和IOS两种App的开发&#xff0c;又以Android为主。 将这段时间的Android开发心得记录如下。 1.开发环境和参考资料 由于学会FQ的时间比较短&#xff08;2016年7月…

为什么本地部署的低代码平台更有优势?

编者按&#xff1a;快速发展的企业需要跟上不断变化的市场趋势。在这种环境下&#xff0c;低代码开发平台可以成为企业快速进入市场的利器。低代码开发的优势可以影响新软件的交付速度&#xff0c;而可视化开发是推动这种无与伦比的速度的关键功能。私有化部署方案和源码交付机…

B站弹幕姬,弹幕礼物感谢,关注感谢,自动回复,房管工具,房管助手,基于java

运行环境 可在所有主要操作系统上运行&#xff0c;并且仅需要安装Java JDK或JRE版本8或更高版本。要检查&#xff0c;请运行java -version&#xff1a; $ java -version java version "1.8.0_121"Bootstrap5 放弃了对 IE 的支持。 以最新版本浏览器示例: ChromeFi…

单片机学习:第一篇 基于Python的树莓派语音助手

title: 单片机学习&#xff1a;第一篇 基于Python的树莓派语音助手 tags: 树莓派,python,语音助手,百度AIP 目录 一、pyaudio录音 二、语音识别 三、与图灵机器人对话 四、语音合成 五、封装 树莓派功能十分强大&#xff0c;作为一个微型电脑&#xff0c;独特的阵脚设计使得树…

获取ipa安装包的最新方式

获取IPA包的的方式 1、利用Apple Configurator 2 2、使用爱思助手 之前我们可以借助PP助手来获取越狱或者非越狱后的IPA安装包&#xff0c;但现在PP助手已经凉凉了&#xff0c;不过我们还是有其他的方式可以获取到IPA包的---《Apple Configurator 2》&#xff1b;这款应用我…

ESP8266上传DHT11数据给私人javaweb服务器实现网页查询数据的电路方案

系列文章目录 第一章ESP8266的java软件仿真测试 第二章ESP8266硬件与软件测试 第三章ESP8266客户端与Java后台服务器联调 第四章ESP8266客户端与JavaWeb服务器联调 第五章ESP8266客户端与JavaWeb服务器网页联调 文章目录 系列文章目录前言一、物联网单片机客户端与网站结合是什…

ESP8266学习-内置网页配置(一)

1、写入一个网页到FLASH指定位置&#xff1a; &#xff08;1&#xff09;找到可以放置网页的位置&#xff1b;扇区&#xff1a;4KBytes-0x001000 ESP8266-01:FLASH:25Q808Mbits1MBytes0x100000: 256扇区 eagle.flash.bin--------------------------------------------0x0000…

iOS:苹果企业证书通过网页分发安装app(PP助手方式)

本文来自转载&#xff0c;原文地址&#xff1a;http://blog.sina.com.cn/s/blog_6afb7d800101fa16.html &#xff08;这个网址也有类似介绍&#xff1a;http://www.cnblogs.com/beginor/archive/2013/01/25/2877200.html&#xff09; 苹果的企业级证书发布的应用&#xff0c;是…