From d45adde4128bf44b11c080e49db694cfd4d75d04 Mon Sep 17 00:00:00 2001 From: zhouhao Date: Mon, 26 Jun 2023 11:44:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=AE=A4=E8=AF=81=E6=A8=A1=E5=9D=97):=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=99=BB=E5=BD=95=E6=97=B6=E5=AF=86=E7=A0=81?= =?UTF-8?q?=E5=8A=A0=E5=AF=86=E4=BB=A5=E5=8F=8A=E9=99=90=E5=88=B6=E5=AF=86?= =?UTF-8?q?=E7=A0=81=E9=94=99=E8=AF=AF=E6=AC=A1=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jetlinks/community/utils/CryptoUtils.java | 135 +++++++++++++ .../authentication-manager/pom.xml | 6 + .../auth/login/UserLoginLogicInterceptor.java | 187 ++++++++++++++++++ .../auth/login/UserLoginProperties.java | 70 +++++++ 4 files changed, 398 insertions(+) create mode 100644 jetlinks-components/common-component/src/main/java/org/jetlinks/community/utils/CryptoUtils.java create mode 100644 jetlinks-manager/authentication-manager/src/main/java/org/jetlinks/community/auth/login/UserLoginLogicInterceptor.java create mode 100644 jetlinks-manager/authentication-manager/src/main/java/org/jetlinks/community/auth/login/UserLoginProperties.java diff --git a/jetlinks-components/common-component/src/main/java/org/jetlinks/community/utils/CryptoUtils.java b/jetlinks-components/common-component/src/main/java/org/jetlinks/community/utils/CryptoUtils.java new file mode 100644 index 00000000..2e58d3b9 --- /dev/null +++ b/jetlinks-components/common-component/src/main/java/org/jetlinks/community/utils/CryptoUtils.java @@ -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); + } + + +} diff --git a/jetlinks-manager/authentication-manager/pom.xml b/jetlinks-manager/authentication-manager/pom.xml index 96758fe8..61a59e52 100644 --- a/jetlinks-manager/authentication-manager/pom.xml +++ b/jetlinks-manager/authentication-manager/pom.xml @@ -63,6 +63,12 @@ provided + + org.hswebframework.web + hsweb-access-logging-aop + ${hsweb.framework.version} + + org.jetlinks.community common-component diff --git a/jetlinks-manager/authentication-manager/src/main/java/org/jetlinks/community/auth/login/UserLoginLogicInterceptor.java b/jetlinks-manager/authentication-manager/src/main/java/org/jetlinks/community/auth/login/UserLoginLogicInterceptor.java new file mode 100644 index 00000000..f014ebcf --- /dev/null +++ b/jetlinks-manager/authentication-manager/src/main/java/org/jetlinks/community/auth/login/UserLoginLogicInterceptor.java @@ -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 redis; + + @GetMapping("/authorize/login/configs") + @Operation(summary = "获取登录所需配置信息") + public Mono> getConfigs() { + + Map map = new ConcurrentHashMap<>(); + List> 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> 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 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 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 recordAuthFailed(AbstractAuthorizationEvent event) { + return createBlockRedisKey(event) + .flatMap(key -> redis + .opsForValue() + .increment(key, 1) + .then(redis.expire(key, properties.getBlock().getTtl()))) + .then(); + } + + private Mono currentRequest() { + return Mono + .deferContextual(ctx -> Mono.justOrEmpty(ctx.getOrEmpty(RequestInfo.class))); + } + + private Mono 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 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; + } + +} diff --git a/jetlinks-manager/authentication-manager/src/main/java/org/jetlinks/community/auth/login/UserLoginProperties.java b/jetlinks-manager/authentication-manager/src/main/java/org/jetlinks/community/auth/login/UserLoginProperties.java new file mode 100644 index 00000000..d200ac94 --- /dev/null +++ b/jetlinks-manager/authentication-manager/src/main/java/org/jetlinks/community/auth/login/UserLoginProperties.java @@ -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 + } + + } +}