封面图片

编程

Spring Security实现密码加密和防暴力破解代码 | 编程教程

大部分web项目都涉及到登录模块。登录的安全问题又非常重要。政府项目涉及的等保也需要我们重视安全问题。这里谈谈java项目的密码加密传输和连续登录失败锁定账号的解决方案。

密码加密传输

主要思路就是在Java后端生成公钥和私钥。通过接口将公钥给到前端,前端拿到公钥进行密码加密传输。后端拿到加密的密码在用私钥解密,然后进行登录认证。

这里使用RSA非对称加密算法。

后端实现

RsaUtil.class

1public class RsaUtil { 2 private static final int KEY_SIZE = 1024; 3 4 public static final String PRIVATE_KEY = "pri"; 5 public static final String PUBLIC_KEY = "pub"; 6 7 private static KeyPair keyPair; 8 9 private static Map<String,String> rsaMap; 10 11 //生成RSA,并存放 12 static { 13 try { 14 Provider provider =new org.bouncycastle.jce.provider.BouncyCastleProvider(); 15 Security.addProvider(provider); 16 SecureRandom random = new SecureRandom(); 17 KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA", provider); 18 generator.initialize(KEY_SIZE,random); 19 keyPair = generator.generateKeyPair(); 20 //将公钥和私钥存放,登录时会不断请求获取公钥,我们可以将其放到缓存中,而不放入数据库了 21 //我在想,这个是不是有必要存放到Redis,在分布式场景中? 22 //貌似有些必要,万一获取到的pubkey是server1中的,拿着server1的pubkey去server2去解密? 23 storeRSA(); 24 } catch(NoSuchAlgorithmException e) { 25 e.printStackTrace(); 26 } 27 } 28 29 /** 30 * 将RSA存入缓存 31 */ 32 private static void storeRSA() { 33 rsaMap = new HashMap<>(); 34 PublicKey publicKey = keyPair.getPublic(); 35 String publicKeyStr = new String(Base64.encodeBase64(publicKey.getEncoded())); 36 rsaMap.put(PRIVATE_KEY, publicKeyStr); 37 38 PrivateKey privateKey = keyPair.getPrivate(); 39 String privateKeyStr = new String(Base64.encodeBase64(privateKey.getEncoded())); 40 rsaMap.put(PUBLIC_KEY, privateKeyStr); 41 } 42 43 /** 44 * 私钥解密(解密前台公钥加密的密文) 45 * 46 * @param encryptText 公钥加密的数据 47 * @return 私钥解密出来的数据 48 * @throws Exception e 49 */ 50 public static String decryptWithPrivate(String encryptText) throws Exception { 51 if(StringUtils.isBlank(encryptText)){ 52 return null; 53 } 54 byte[] en_byte = Base64.decodeBase64(encryptText.getBytes()); 55 //byte[] en_byte = Hex.decode(encryptText); 56 Provider provider = new org.bouncycastle.jce.provider.BouncyCastleProvider(); 57 Security.addProvider(provider); 58 //之前我只写了RSA,出现了乱码+明文 参考:https://blog.csdn.net/qq_39420411/article/details/94056654 59 Cipher ci = Cipher.getInstance("RSA/ECB/PKCS1Padding", provider); 60 PrivateKey privateKey = keyPair.getPrivate(); 61 ci.init(Cipher.DECRYPT_MODE, privateKey); 62 byte[] res = ci.doFinal(en_byte); 63 return new String(res); 64 } 65 66 /** 67 * java端 使用公钥加密(此方法暂时用不到) 68 * @param plaintext 明文内容 69 * @return byte[] 70 * @throws UnsupportedEncodingException e 71 */ 72 public static byte[] encrypt(String plaintext) throws UnsupportedEncodingException { 73 String encode = URLEncoder.encode(plaintext, "utf-8"); 74 RSAPublicKey rsaPublicKey = (RSAPublicKey)keyPair.getPublic(); 75 //获取公钥指数 76 BigInteger e = rsaPublicKey.getPublicExponent(); 77 //获取公钥系数 78 BigInteger n = rsaPublicKey.getModulus(); 79 //获取明文字节数组 80 BigInteger m = new BigInteger(encode.getBytes()); 81 //进行明文加密 82 BigInteger res = m.modPow(e, n); 83 return res.toByteArray(); 84 85 } 86 87 /** 88 * java端 使用私钥解密(此方法暂时用不到) 89 * @param cipherText 加密后的字节数组 90 * @return 解密后的数据 91 * @throws UnsupportedEncodingException e 92 */ 93 public static String decrypt(byte[] cipherText) throws UnsupportedEncodingException { 94 RSAPrivateKey prk = (RSAPrivateKey) keyPair.getPrivate(); 95 // 获取私钥参数-指数/系数 96 BigInteger d = prk.getPrivateExponent(); 97 BigInteger n = prk.getModulus(); 98 // 读取密文 99 BigInteger c = new BigInteger(cipherText); 100 // 进行解密 101 BigInteger m = c.modPow(d, n); 102 // 解密结果-字节数组 103 byte[] mt = m.toByteArray(); 104 //转成String,此时是乱码 105 String en = new String(mt); 106 //再进行编码,最后返回解密后得到的明文 107 return URLDecoder.decode(en, "UTF-8"); 108 } 109 110 /** 111 * 获取公钥 112 * @return 公钥 113 */ 114 public static String getPublicKey(){ 115 return rsaMap.get(PUBLIC_KEY); 116 } 117 118 /** 119 * 获取私钥 120 * @return 私钥 121 */ 122 public static String getPrivateKey(){ 123 return rsaMap.get(PRIVATE_KEY); 124 } 125 126 public static void main(String[] args) throws UnsupportedEncodingException { 127 System.out.println(RsaUtil.getPrivateKey()); 128 System.out.println(RsaUtil.getPublicKey()); 129 byte[] usernames = RsaUtil.encrypt("username66"); 130 System.out.println(RsaUtil.decrypt(usernames)); 131 } 132 133}

提供公钥接口

1@GetMapping("/getPublicKey") 2public String getPublicKey(){ 3 return RsaUtil.getPublicKey(); 4}

登录接口进行解密认证

1String password = RsaUtil.decryptWithPrivate(user.getPassword()); 2UserVO userVO = loginService.login(user.getUsername(), password);

前端实现

vue使用jsencrypt

1npm install jsencrypt --save

接下来需要在vue入口文件main.js中引入

1import { JSEncrypt } from 'jsencrypt'

使用

1 2let encrypt = new JSEncrypt() 3encrypt.setPublicKey('后台给的公钥') 4let data = encrypt.encrypt(password)

连续登录多次失败锁定账号

通过注册一个登录失败监听器来监听失败事件,使用定时过期缓存来记录用户的登录失败次数。这里使用的是google guava的缓存组件。如果超过登录失败次数,则进行账号锁定。解锁方式是到达缓存的过期时间。使用spring security框架。

LoginAttemptService.class

1@Service 2@Slf4j 3public class LoginAttemptService { 4 5 private final LoadingCache<String, Integer> attemptsCache; 6 7 public LoginAttemptService() { 8 super(); 9 attemptsCache = CacheBuilder.newBuilder(). 10 expireAfterWrite(30, TimeUnit.MINUTES).build(new CacheLoader<String, Integer>() { 11 @Override 12 public Integer load(String key) { 13 return 0; 14 } 15 }); 16 } 17 18 public void loginSucceeded(String key) { 19 if (key != null) { 20 attemptsCache.invalidate(key); 21 } 22 } 23 24 public void loginFailed(String key) { 25 int attempts = 0; 26 try { 27 attempts = attemptsCache.get(key); 28 } catch (ExecutionException e) { 29 log.error("获取缓存失败:{}", e.getMessage()); 30 } 31 attempts++; 32 attemptsCache.put(key, attempts); 33 } 34 35 public boolean isBlocked(String key) { 36 try { 37 int maxAttempt = 10; 38 return attemptsCache.get(key) >= maxAttempt; 39 } catch (ExecutionException e) { 40 return false; 41 } 42 } 43}

该类负责记录登录失败缓存和验证登录失败是否超限制。

登录失败监听器

AuthenticationFailureListener.class

1@Component 2public class AuthenticationFailureListener 3 implements ApplicationListener<AuthenticationFailureBadCredentialsEvent> { 4 5 private final LoginAttemptService loginAttemptService; 6 7 public AuthenticationFailureListener(LoginAttemptService loginAttemptService) { 8 this.loginAttemptService = loginAttemptService; 9 } 10 11 //打印details中内容,查看用户标识。用来替换代码中的details.get("username")。 12 @Override 13 public void onApplicationEvent(AuthenticationFailureBadCredentialsEvent e) { 14 Map<String, String> details = (Map<String, String>) e.getAuthentication().getDetails(); 15 loginAttemptService.loginFailed(details.get("username")); 16 } 17}

新建类实现接口UserDetailsService loadUserByUsername方法。

1@Override 2 public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { 3 if (loginAttemptService.isBlocked(username)) { 4 log.info("账号{}被锁定", username); 5 throw new LockedException(MessageConstant.ACCOUNT_LOCKED); 6 } 7 //todo 查询登录用户信息逻辑 8 if (!customUserDetails.isEnabled()) { 9 throw new DisabledException(MessageConstant.ACCOUNT_DISABLED); 10 } else if (!securityUser.isAccountNonLocked()) { 11 throw new LockedException(MessageConstant.ACCOUNT_LOCKED); 12 } else if (!securityUser.isAccountNonExpired()) { 13 throw new AccountExpiredException(MessageConstant.ACCOUNT_EXPIRED); 14 } else if (!securityUser.isCredentialsNonExpired()) { 15 throw new CredentialsExpiredException(MessageConstant.CREDENTIALS_EXPIRED); 16 } 17 return customUserDetails; 18 }

总结

这个是在项目中遇到问题,总结的解决方案,主要应对密码的登录方式。现在越来越多的方式摒弃了密码的登录方式。

互联网发展了20多年,所有环节都巨大改善,密码登录还是20年前的用法。

多年来,业界一直努力,试图解决密码问题。近两年终于有了突破,各大公司达成一致,设计出了一套密码的替代方案:密钥登陆,英文叫做 Passkey。感兴趣的可以去看看。

Passkey 这个方案可以做到,登录不需要密码,解决了上面提到的所有问题,而且用户很容易上手,主要厂商已经全部支持。

2022年 WWDC 大会,苹果宣布支持 Passkey。

2023年5月,谷歌和微软同时宣布,全面接入 Passkey。

2023年07月20日
在初学者眼中,世界充满了可能;专家眼中,世界大都已经既定。--铃木俊隆