feat(认证模块): 增加登录时密码加密以及限制密码错误次数
This commit is contained in:
parent
122750aa93
commit
d45adde412
|
|
@ -0,0 +1,135 @@
|
|||
package org.jetlinks.community.utils;
|
||||
|
||||
import lombok.SneakyThrows;
|
||||
import org.apache.commons.codec.binary.Base64;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.SecretKeyFactory;
|
||||
import javax.crypto.spec.DESKeySpec;
|
||||
import javax.crypto.spec.IvParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.security.*;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.util.Arrays;
|
||||
|
||||
public class CryptoUtils {
|
||||
|
||||
private static Key generateDESKey(byte[] password) throws Exception {
|
||||
DESKeySpec dks = new DESKeySpec(password);
|
||||
SecretKeyFactory keyFactory = SecretKeyFactory.getInstance("DES");
|
||||
return keyFactory.generateSecret(dks);
|
||||
}
|
||||
|
||||
|
||||
@SneakyThrows
|
||||
public static byte[] encryptDES(byte[] password, byte[] ivParameter, byte[] data) {
|
||||
Key secretKey = generateDESKey(password);
|
||||
Cipher cipher = Cipher.getInstance("DES/CBC/PKCS5Padding");
|
||||
IvParameterSpec iv = new IvParameterSpec(ivParameter);
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey, iv);
|
||||
return cipher.doFinal(data);
|
||||
}
|
||||
|
||||
@SneakyThrows
|
||||
public static byte[] decryptDES(byte[] password, byte[] ivParameter, byte[] data) {
|
||||
Key secretKey = generateDESKey(password);
|
||||
Cipher cipher = Cipher.getInstance("DES/CBC/PKCS5Padding");
|
||||
IvParameterSpec iv = new IvParameterSpec(ivParameter);
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, iv);
|
||||
return cipher.doFinal(data);
|
||||
}
|
||||
|
||||
@SneakyThrows
|
||||
public static byte[] encryptAESCBC(byte[] password, byte[] ivParameter, byte[] data) {
|
||||
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
|
||||
Key secretKey = new SecretKeySpec(fillBit(password, cipher.getBlockSize()), "AES");
|
||||
IvParameterSpec iv = new IvParameterSpec(fillBit(ivParameter, cipher.getBlockSize()));
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey, iv);
|
||||
return cipher.doFinal(data);
|
||||
}
|
||||
|
||||
@SneakyThrows
|
||||
public static byte[] decryptAESCBC(byte[] password, byte[] ivParameter, byte[] data) {
|
||||
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
|
||||
Key secretKey = new SecretKeySpec(fillBit(password, cipher.getBlockSize()), "AES");
|
||||
IvParameterSpec iv = new IvParameterSpec(fillBit(ivParameter, cipher.getBlockSize()));
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, iv);
|
||||
return cipher.doFinal(data);
|
||||
}
|
||||
|
||||
@SneakyThrows
|
||||
public static byte[] encryptAESECB(byte[] password, byte[] data) {
|
||||
Cipher cipher = Cipher.getInstance("AES/ECB/NoPadding");
|
||||
Key secretKey = new SecretKeySpec(fillBit(password, cipher.getBlockSize()), "AES");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey);
|
||||
return cipher.doFinal(fillBit(data, cipher.getBlockSize()));
|
||||
}
|
||||
|
||||
@SneakyThrows
|
||||
public static byte[] decryptAESECB(byte[] password, byte[] data) {
|
||||
Cipher cipher = Cipher.getInstance("AES/ECB/NoPadding");
|
||||
Key secretKey = new SecretKeySpec(fillBit(password, cipher.getBlockSize()), "AES");
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey);
|
||||
byte[] decrypted = cipher.doFinal(data);
|
||||
if (decrypted[decrypted.length - 1] != 0x00) {
|
||||
return decrypted;
|
||||
}
|
||||
//去除补位的0x00
|
||||
for (int i = decrypted.length - 1; i >= 0; i--) {
|
||||
if (decrypted[i] != 0x00) {
|
||||
return Arrays.copyOf(decrypted, i + 1);
|
||||
}
|
||||
}
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
private static byte[] fillBit(byte[] data, int blockSize) {
|
||||
int len = (data.length / blockSize + (data.length % blockSize == 0 ? 0 : 1)) * 16;
|
||||
if (len == data.length) {
|
||||
return data;
|
||||
}
|
||||
return Arrays.copyOf(data, len);
|
||||
}
|
||||
|
||||
|
||||
@SneakyThrows
|
||||
public static KeyPair generateRSAKey() {
|
||||
KeyPairGenerator keyPairGen;
|
||||
keyPairGen = KeyPairGenerator.getInstance("RSA");
|
||||
keyPairGen.initialize(512);
|
||||
return keyPairGen.generateKeyPair();
|
||||
}
|
||||
|
||||
@SneakyThrows
|
||||
public static byte[] decryptRSA(byte[] data, Key key) {
|
||||
Cipher cipher = Cipher.getInstance("RSA");
|
||||
cipher.init(Cipher.DECRYPT_MODE, key);
|
||||
return cipher.doFinal(data);
|
||||
}
|
||||
|
||||
@SneakyThrows
|
||||
public static byte[] encryptRSA(byte[] data, Key key) {
|
||||
Cipher cipher = Cipher.getInstance("RSA");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key);
|
||||
return cipher.doFinal(data);
|
||||
}
|
||||
|
||||
@SneakyThrows
|
||||
public static PublicKey decodeRSAPublicKey(String base64) {
|
||||
byte[] keyBytes = Base64.decodeBase64(base64);
|
||||
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
|
||||
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
|
||||
return keyFactory.generatePublic(keySpec);
|
||||
}
|
||||
|
||||
@SneakyThrows
|
||||
public static PrivateKey decodeRSAPrivateKey(String base64) {
|
||||
byte[] keyBytes = Base64.decodeBase64(base64);
|
||||
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes);
|
||||
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
|
||||
return keyFactory.generatePrivate(keySpec);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -63,6 +63,12 @@
|
|||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.hswebframework.web</groupId>
|
||||
<artifactId>hsweb-access-logging-aop</artifactId>
|
||||
<version>${hsweb.framework.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.jetlinks.community</groupId>
|
||||
<artifactId>common-component</artifactId>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,187 @@
|
|||
package org.jetlinks.community.auth.login;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.hswebframework.web.authorization.annotation.Authorize;
|
||||
import org.hswebframework.web.authorization.events.AbstractAuthorizationEvent;
|
||||
import org.hswebframework.web.authorization.events.AuthorizationDecodeEvent;
|
||||
import org.hswebframework.web.authorization.events.AuthorizationFailedEvent;
|
||||
import org.hswebframework.web.authorization.exception.AccessDenyException;
|
||||
import org.hswebframework.web.authorization.exception.AuthenticationException;
|
||||
import org.hswebframework.web.exception.ValidationException;
|
||||
import org.hswebframework.web.id.IDGenerator;
|
||||
import org.hswebframework.web.logging.RequestInfo;
|
||||
import org.hswebframework.web.utils.DigestUtils;
|
||||
import org.jetlinks.core.utils.Reactors;
|
||||
import org.jetlinks.community.utils.CryptoUtils;
|
||||
import org.jetlinks.reactor.ql.utils.CastUtils;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.data.redis.core.ReactiveRedisOperations;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.security.KeyPair;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@RestController
|
||||
@Authorize(ignore = true)
|
||||
@RequestMapping
|
||||
@Tag(name = "登录配置接口")
|
||||
@Hidden
|
||||
@AllArgsConstructor
|
||||
@Slf4j
|
||||
public class UserLoginLogicInterceptor {
|
||||
|
||||
private final UserLoginProperties properties;
|
||||
|
||||
private final ReactiveRedisOperations<String, String> redis;
|
||||
|
||||
@GetMapping("/authorize/login/configs")
|
||||
@Operation(summary = "获取登录所需配置信息")
|
||||
public Mono<Map<String, Object>> getConfigs() {
|
||||
|
||||
Map<String, Object> map = new ConcurrentHashMap<>();
|
||||
List<Mono<Void>> jobs = new ArrayList<>();
|
||||
|
||||
if (properties.getEncrypt().isEnabled()) {
|
||||
jobs.add(getEncryptKey()
|
||||
.doOnNext(enc -> map.put("encrypt", enc))
|
||||
.then());
|
||||
} else {
|
||||
map.put("encrypt", Collections.singletonMap("enabled", false));
|
||||
}
|
||||
// TODO: 2023/6/25 其他配置
|
||||
|
||||
return Flux.merge(jobs)
|
||||
.then(Mono.just(map));
|
||||
}
|
||||
|
||||
@GetMapping("/authorize/encrypt-key")
|
||||
@Operation(summary = "获取登录加密key")
|
||||
public Mono<Map<String, Object>> getEncryptKey() {
|
||||
if (!properties.getEncrypt().isEnabled()) {
|
||||
return Mono.empty();
|
||||
}
|
||||
String id = IDGenerator.RANDOM.generate();
|
||||
KeyPair rasKey = CryptoUtils.generateRSAKey();
|
||||
String pubKeyBase64 = Base64.getEncoder().encodeToString(rasKey.getPublic().getEncoded());
|
||||
String priKeyBase64 = Base64.getEncoder().encodeToString(rasKey.getPrivate().getEncoded());
|
||||
|
||||
Map<String, Object> value = new LinkedHashMap<>();
|
||||
value.put("enabled", true);
|
||||
value.put("publicKey", pubKeyBase64);
|
||||
value.put("id", id);
|
||||
return redis
|
||||
.opsForValue()
|
||||
.set(createEncRedisKey(id), priKeyBase64, properties.getEncrypt().getKeyTtl())
|
||||
.thenReturn(value);
|
||||
}
|
||||
|
||||
@EventListener
|
||||
public void handleAuthEvent(AuthorizationDecodeEvent event) {
|
||||
if (properties.getBlock().isEnabled()) {
|
||||
event.async(checkBlocked(event));
|
||||
}
|
||||
|
||||
if (properties.getEncrypt().isEnabled()) {
|
||||
event.async(Mono.defer(() -> doDecrypt(event)));
|
||||
}
|
||||
}
|
||||
|
||||
Mono<Void> doDecrypt(AuthorizationDecodeEvent event) {
|
||||
String encId = event
|
||||
.getParameter("encryptId")
|
||||
.map(String::valueOf)
|
||||
.orElseThrow(() -> new ValidationException("encryptId", "encryptId is required"));
|
||||
String redisKey = createEncRedisKey(encId);
|
||||
return redis
|
||||
.opsForValue()
|
||||
.get(redisKey)
|
||||
.map(privateKey -> {
|
||||
event.setPassword(
|
||||
new String(
|
||||
CryptoUtils.decryptRSA(Base64.getDecoder().decode(event.getPassword()),
|
||||
CryptoUtils.decodeRSAPrivateKey(privateKey))
|
||||
)
|
||||
);
|
||||
return true;
|
||||
})
|
||||
.onErrorResume(err -> {
|
||||
log.warn("decrypt password error", err);
|
||||
return Reactors.ALWAYS_FALSE;
|
||||
})
|
||||
.defaultIfEmpty(false)
|
||||
.then(redis.opsForValue().delete(redisKey))
|
||||
.doOnSuccess(success -> {
|
||||
if (!success) {
|
||||
throw new AuthenticationException(AuthenticationException.ILLEGAL_PASSWORD);
|
||||
}
|
||||
})
|
||||
.then();
|
||||
|
||||
}
|
||||
|
||||
@EventListener
|
||||
public void handleAuthFailed(AuthorizationFailedEvent event) {
|
||||
if (event.getException() instanceof AuthenticationException) {
|
||||
event.async(recordAuthFailed(event));
|
||||
}
|
||||
}
|
||||
|
||||
private Mono<Void> recordAuthFailed(AbstractAuthorizationEvent event) {
|
||||
return createBlockRedisKey(event)
|
||||
.flatMap(key -> redis
|
||||
.opsForValue()
|
||||
.increment(key, 1)
|
||||
.then(redis.expire(key, properties.getBlock().getTtl())))
|
||||
.then();
|
||||
}
|
||||
|
||||
private Mono<RequestInfo> currentRequest() {
|
||||
return Mono
|
||||
.deferContextual(ctx -> Mono.justOrEmpty(ctx.getOrEmpty(RequestInfo.class)));
|
||||
}
|
||||
|
||||
private Mono<String> createBlockRedisKey(AbstractAuthorizationEvent event) {
|
||||
return currentRequest()
|
||||
.map(request -> {
|
||||
String hex = DigestUtils.md5Hex(digest -> {
|
||||
if (properties.getBlock().hasScope(UserLoginProperties.BlockLogic.Scope.ip)) {
|
||||
digest.update(properties.getBlock().getRealIp(request.getIpAddr()).getBytes());
|
||||
}
|
||||
if (properties.getBlock().hasScope(UserLoginProperties.BlockLogic.Scope.username)) {
|
||||
digest.update(event.getUsername().trim().getBytes());
|
||||
}
|
||||
});
|
||||
return "login:blocked:" + hex;
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<Void> checkBlocked(AuthorizationDecodeEvent event) {
|
||||
|
||||
return this
|
||||
.createBlockRedisKey(event)
|
||||
.flatMap(key -> redis
|
||||
.opsForValue()
|
||||
.get(key)
|
||||
.doOnNext(l -> {
|
||||
if (CastUtils.castNumber(l).intValue() >= properties.getBlock().getMaximum()) {
|
||||
throw new AccessDenyException("error.user.login.blocked");
|
||||
}
|
||||
}))
|
||||
.then();
|
||||
}
|
||||
|
||||
|
||||
private static String createEncRedisKey(String encryptId) {
|
||||
return "login:encrypt-key:" + encryptId;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
package org.jetlinks.community.auth.login;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "user.login")
|
||||
public class UserLoginProperties {
|
||||
|
||||
//加密相关配置
|
||||
private EncryptionLogic encrypt = new EncryptionLogic();
|
||||
|
||||
//登录失败限制相关配置
|
||||
private BlockLogic block = new BlockLogic();
|
||||
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public static class EncryptionLogic {
|
||||
private boolean enabled;
|
||||
|
||||
private Duration keyTtl = Duration.ofMinutes(5);
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public static class BlockLogic {
|
||||
|
||||
//开启登录失败限制
|
||||
private boolean enabled;
|
||||
//限制作用域,默认ip+用户名
|
||||
private Scope[] scopes = Scope.values();
|
||||
//最大登录失败次数
|
||||
private int maximum = 5;
|
||||
|
||||
//代理深度,默认为1,用于获取经过代理后的客户端真实IP地址
|
||||
private int proxyDepth = 1;
|
||||
//限制时间,默认10分钟
|
||||
private Duration ttl = Duration.ofMinutes(10);
|
||||
|
||||
public String getRealIp(String ipAddress) {
|
||||
String[] split = ipAddress.split(",");
|
||||
if (split.length > proxyDepth) {
|
||||
return split[split.length - proxyDepth - 1].trim();
|
||||
}
|
||||
return split[split.length - 1].trim();
|
||||
}
|
||||
|
||||
public boolean hasScope(Scope scope) {
|
||||
for (Scope scope1 : scopes) {
|
||||
if (scope1 == scope) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public enum Scope {
|
||||
ip,
|
||||
username
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue