Browse Source

系统优化

woody 1 year ago
parent
commit
9f74185443
25 changed files with 283 additions and 132 deletions
  1. 4 28
      framework-base/src/main/java/com/chelvc/framework/base/context/SessionContextHolder.java
  2. 1 1
      framework-base/src/main/java/com/chelvc/framework/base/util/SpringUtils.java
  3. 1 1
      framework-boot/pom.xml
  4. 0 5
      framework-common/src/main/java/com/chelvc/framework/common/model/Session.java
  5. 30 0
      framework-common/src/main/java/com/chelvc/framework/common/util/ObjectUtils.java
  6. 11 1
      framework-database/src/main/java/com/chelvc/framework/database/config/DatabaseConfigurer.java
  7. 0 1
      framework-feign/src/main/java/com/chelvc/framework/feign/interceptor/FeignHeaderInterceptor.java
  8. 13 2
      framework-location/src/main/java/com/chelvc/framework/location/support/CacheableLocationHandler.java
  9. 0 1
      framework-oauth/pom.xml
  10. 3 37
      framework-oauth/src/main/java/com/chelvc/framework/oauth/config/OAuthConfigurer.java
  11. 19 8
      framework-oauth/src/main/java/com/chelvc/framework/oauth/context/OAuthContextHolder.java
  12. 0 36
      framework-oauth/src/main/java/com/chelvc/framework/oauth/token/RedisTokenActiveValidator.java
  13. 14 1
      framework-oauth/src/main/java/com/chelvc/framework/oauth/token/RedisTokenStore.java
  14. 90 0
      framework-oauth/src/main/java/com/chelvc/framework/oauth/token/RedisTokenValidator.java
  15. 3 0
      framework-oauth/src/main/resources/i18n/messages.properties
  16. 49 0
      framework-redis/src/main/java/com/chelvc/framework/redis/context/RedisContextHolder.java
  17. 4 1
      framework-security/src/main/java/com/chelvc/framework/security/interceptor/SecurityValidateInterceptor.java
  18. 1 0
      framework-security/src/main/resources/i18n/messages.properties
  19. 7 0
      framework-sms/src/main/java/com/chelvc/framework/sms/TemplateSmsHandler.java
  20. 5 5
      framework-sms/src/main/java/com/chelvc/framework/sms/config/SmsProperties.java
  21. 9 1
      framework-sms/src/main/java/com/chelvc/framework/sms/support/AliyunSmsHandler.java
  22. 3 2
      framework-sms/src/main/java/com/chelvc/framework/sms/support/DefaultCaptchaSmsHandler.java
  23. 5 0
      framework-sms/src/main/java/com/chelvc/framework/sms/support/DelegatingTemplateSmsHandler.java
  24. 9 1
      framework-sms/src/main/java/com/chelvc/framework/sms/support/TencentSmsHandler.java
  25. 2 0
      framework-sms/src/main/resources/i18n/messages.properties

+ 4 - 28
framework-base/src/main/java/com/chelvc/framework/base/context/SessionContextHolder.java

@@ -93,11 +93,6 @@ public class SessionContextHolder implements ServletRequestListener {
      */
     public static final String HEADER_SIGNATURE = "signature";
 
-    /**
-     * 设备指纹请求头
-     */
-    public static final String HEADER_FINGERPRINT = "fingerprint";
-
     /**
      * 认证信息请求头
      */
@@ -165,8 +160,8 @@ public class SessionContextHolder implements ServletRequestListener {
         } else {
             session = Session.builder().id(id).scope(scope).anonymous(anonymous).host(original.getHost())
                     .device(original.getDevice()).channel(original.getChannel()).platform(original.getPlatform())
-                    .terminal(original.getTerminal()).version(original.getVersion())
-                    .fingerprint(original.getFingerprint()).timestamp(original.getTimestamp()).build();
+                    .terminal(original.getTerminal()).version(original.getVersion()).timestamp(original.getTimestamp())
+                    .build();
         }
         deque.push(session);
         return session;
@@ -383,25 +378,6 @@ public class SessionContextHolder implements ServletRequestListener {
         return StringUtils.ifEmpty(request.getHeader(HEADER_SIGNATURE), (String) null);
     }
 
-    /**
-     * 获取设备指纹
-     *
-     * @return 设备指纹
-     */
-    public static String getFingerprint() {
-        return ObjectUtils.ifNull(getSession(false), Session::getFingerprint);
-    }
-
-    /**
-     * 获取设备指纹
-     *
-     * @param request Http请求对象
-     * @return 设备指纹
-     */
-    public static String getFingerprint(@NonNull HttpServletRequest request) {
-        return StringUtils.ifEmpty(request.getHeader(HEADER_FINGERPRINT), (String) null);
-    }
-
     /**
      * 获取认证信息
      *
@@ -561,8 +537,8 @@ public class SessionContextHolder implements ServletRequestListener {
         HttpServletRequest request = (HttpServletRequest) event.getServletRequest();
         Session session = Session.builder().id(getId(request)).host(getHost(request)).scope(getScope(request))
                 .device(getDevice(request)).channel(getChannel(request)).platform(getPlatform(request))
-                .terminal(getTerminal(request)).version(getVersion(request)).fingerprint(getFingerprint(request))
-                .anonymous(isAnonymous(request)).timestamp(getTimestamp(request)).build();
+                .terminal(getTerminal(request)).version(getVersion(request)).anonymous(isAnonymous(request))
+                .timestamp(getTimestamp(request)).build();
         setSession(session);
     }
 

+ 1 - 1
framework-base/src/main/java/com/chelvc/framework/base/util/SpringUtils.java

@@ -278,7 +278,7 @@ public final class SpringUtils {
                     classes.add(clazz);
                 }
             } catch (IOException | ClassNotFoundException e) {
-                throw new RuntimeException();
+                throw new RuntimeException(e);
             }
         }
         return classes.build().distinct().collect(Collectors.toList());

+ 1 - 1
framework-boot/pom.xml

@@ -69,7 +69,7 @@
                 <artifactId>apidoc-maven-plugin</artifactId>
                 <version>${apidoc-maven-plugin.version}</version>
                 <configuration>
-                    <includeHeaders>{String} scope 应用范围,{String} device 设备标识,{String} channel 渠道来源,{String} platform 平台标识: PC(PC)、IOS(苹果)、ANDROID(安卓),{String} terminal 终端标识: WEB(Web)、APP(App)、APPLET(小程序),{String} version 终端版本,{Long} timestamp 请求时间戳,{String} signature 签名信息,{String} fingerprint 设备指纹,{String} authorization 认证信息</includeHeaders>
+                    <includeHeaders>{String} device 设备标识,{String} channel 渠道来源,{String} platform 平台标识: PC(PC)、IOS(苹果)、ANDROID(安卓),{String} terminal 终端标识: WEB(Web)、APP(App)、APPLET(小程序),{String} version 终端版本,{Long} timestamp 请求时间戳,{String} signature 签名信息,{String} authorization 认证信息</includeHeaders>
                     <includeGroupIdentities>com.chelvc.framework</includeGroupIdentities>
                     <excludeClasses>com.chelvc.framework.base.interceptor.GlobalExceptionInterceptor</excludeClasses>
                     <analyserFactoryClass>com.chelvc.framework.base.apidoc.MethodAnalyserFactory</analyserFactoryClass>

+ 0 - 5
framework-common/src/main/java/com/chelvc/framework/common/model/Session.java

@@ -58,11 +58,6 @@ public class Session implements Serializable {
      */
     private String version;
 
-    /**
-     * 设备指纹
-     */
-    private String fingerprint;
-
     /**
      * 是否匿名
      */

+ 30 - 0
framework-common/src/main/java/com/chelvc/framework/common/util/ObjectUtils.java

@@ -1236,4 +1236,34 @@ public final class ObjectUtils {
         }
         return Stream.of(object);
     }
+
+    /**
+     * 获取字符串长度
+     *
+     * @param string 字符序列对象实例
+     * @return 字符串长度
+     */
+    public static int size(CharSequence string) {
+        return string == null ? 0 : string.length();
+    }
+
+    /**
+     * 获取字典元素个数
+     *
+     * @param map 字典对象实例
+     * @return 元素个数
+     */
+    public static int size(Map<?, ?> map) {
+        return map == null ? 0 : map.size();
+    }
+
+    /**
+     * 获取集合元素个数
+     *
+     * @param collection 集合对象实例
+     * @return 元素个数
+     */
+    public static int size(Collection<?> collection) {
+        return collection == null ? 0 : collection.size();
+    }
 }

+ 11 - 1
framework-database/src/main/java/com/chelvc/framework/database/config/DatabaseConfigurer.java

@@ -13,6 +13,7 @@ import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerIntercept
 import com.chelvc.framework.common.function.Executor;
 import com.chelvc.framework.common.function.Handler;
 import com.chelvc.framework.common.function.Provider;
+import com.chelvc.framework.common.util.IdentityUtils;
 import com.chelvc.framework.common.util.ObjectUtils;
 import com.chelvc.framework.database.context.Transactor;
 import com.chelvc.framework.database.interceptor.DeletedExcludeHandler;
@@ -20,6 +21,7 @@ import com.chelvc.framework.database.support.CustomerSqlInjector;
 import com.chelvc.framework.redis.context.RedisContextHolder;
 import lombok.NonNull;
 import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
 import org.springframework.context.ApplicationContext;
@@ -34,6 +36,7 @@ import org.springframework.transaction.annotation.Transactional;
  * @author Woody
  * @date 2024/1/30
  */
+@Slf4j
 @Configuration
 @RequiredArgsConstructor(onConstructor = @__(@Autowired))
 public class DatabaseConfigurer {
@@ -76,7 +79,14 @@ public class DatabaseConfigurer {
     @Bean
     @ConditionalOnClass(name = "com.chelvc.framework.redis.context.RedisContextHolder")
     public IdentifierGenerator identifierGenerator() {
-        return entity -> RedisContextHolder.identity();
+        return entity -> {
+            try {
+                return RedisContextHolder.identity();
+            } catch (Exception e) {
+                log.warn("Redis identifier generate failed: {}", e.getMessage());
+                return IdentityUtils.generate();
+            }
+        };
     }
 
     @Bean

+ 0 - 1
framework-feign/src/main/java/com/chelvc/framework/feign/interceptor/FeignHeaderInterceptor.java

@@ -38,7 +38,6 @@ public class FeignHeaderInterceptor implements RequestInterceptor {
             template.header(SessionContextHolder.HEADER_ANONYMOUS, String.valueOf(session.isAnonymous()));
             template.header(SessionContextHolder.HEADER_TIMESTAMP,
                     (String) ObjectUtils.ifNull(session.getTimestamp(), String::valueOf));
-            template.header(SessionContextHolder.HEADER_FINGERPRINT, session.getFingerprint());
         }
         if (log.isDebugEnabled()) {
             log.debug("Feign request headers: {}", template.headers());

+ 13 - 2
framework-location/src/main/java/com/chelvc/framework/location/support/CacheableLocationHandler.java

@@ -8,6 +8,7 @@ import com.chelvc.framework.location.Address;
 import com.chelvc.framework.location.LocationHandler;
 import com.chelvc.framework.redis.context.RedisContextHolder;
 import lombok.NonNull;
+import lombok.extern.slf4j.Slf4j;
 
 /**
  * 可缓存的地址定位实现
@@ -15,6 +16,7 @@ import lombok.NonNull;
  * @author Woody
  * @date 2024/1/30
  */
+@Slf4j
 public class CacheableLocationHandler implements LocationHandler {
     private final int expiration;
     private final LocationHandler handler;
@@ -33,12 +35,21 @@ public class CacheableLocationHandler implements LocationHandler {
      */
     private Address execute(String code, Supplier<Address> supplier) {
         String key = "location:" + code;
-        Address address = (Address) RedisContextHolder.getDefaultTemplate().opsForValue().get(key);
+        Address address = null;
+        try {
+            address = (Address) RedisContextHolder.getDefaultTemplate().opsForValue().get(key);
+        } catch (Exception e) {
+            log.warn("Redis location address get failed: {}", e.getMessage());
+        }
         if (address == null && (address = supplier.get()) != null) {
             // 为避免缓存雪崩,缓存过期时间增加随机数
             int random = ThreadLocalRandom.current().nextInt(60);
             Duration duration = Duration.ofSeconds(this.expiration + random);
-            RedisContextHolder.getDefaultTemplate().opsForValue().set(key, address, duration);
+            try {
+                RedisContextHolder.getDefaultTemplate().opsForValue().set(key, address, duration);
+            } catch (Exception e) {
+                log.warn("Redis location address save failed: {}", e.getMessage());
+            }
         }
         return address;
     }

+ 0 - 1
framework-oauth/pom.xml

@@ -23,7 +23,6 @@
             <groupId>com.chelvc.framework</groupId>
             <artifactId>framework-redis</artifactId>
             <version>${framework-redis.version}</version>
-            <scope>provided</scope>
         </dependency>
         <dependency>
             <groupId>org.springframework.security</groupId>

+ 3 - 37
framework-oauth/src/main/java/com/chelvc/framework/oauth/config/OAuthConfigurer.java

@@ -22,8 +22,7 @@ import com.chelvc.framework.common.util.ObjectUtils;
 import com.chelvc.framework.common.util.StringUtils;
 import com.chelvc.framework.oauth.annotation.Authorize;
 import com.chelvc.framework.oauth.context.OAuthContextHolder;
-import com.chelvc.framework.oauth.token.TokenActiveValidator;
-import com.google.common.collect.Lists;
+import com.chelvc.framework.oauth.token.RedisTokenValidator;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.aop.framework.AopProxyUtils;
@@ -40,14 +39,9 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
 import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
 import org.springframework.security.config.http.SessionCreationPolicy;
-import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
 import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
 import org.springframework.security.oauth2.core.OAuth2Error;
-import org.springframework.security.oauth2.core.OAuth2TokenValidator;
-import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
-import org.springframework.security.oauth2.jwt.Jwt;
 import org.springframework.security.oauth2.jwt.JwtDecoder;
-import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
 import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
 import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
 import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
@@ -78,36 +72,8 @@ public class OAuthConfigurer extends WebSecurityConfigurerAdapter {
         SecretKey key = new SecretKeySpec(secret.getBytes(), AESUtils.ALGORITHM);
         NimbusJwtDecoder decoder = NimbusJwtDecoder.withSecretKey(key).build();
 
-        // 添加JWT过期校验器
-        List<OAuth2TokenValidator<Jwt>> validators = Lists.newLinkedList();
-        validators.add(new OAuth2TokenValidator<Jwt>() {
-            private final JwtTimestampValidator delegate = new JwtTimestampValidator();
-
-            @Override
-            public OAuth2TokenValidatorResult validate(Jwt jwt) {
-                OAuth2TokenValidatorResult result = this.delegate.validate(jwt);
-                if (result != null && result.hasErrors()) {
-                    throw new OAuth2AuthenticationException(new OAuth2Error(
-                            "TOKEN_EXPIRED", "Token has expired", null
-                    ));
-                }
-                return result;
-            }
-        });
-
-        // 加载自定义JWT验证器
-        validators.addAll(this.applicationContext.getBeansOfType(TokenActiveValidator.class).values());
-
-        // 添加令牌会话初始化拦截器
-        validators.add(jwt -> {
-            SessionContextHolder.initializeSession(
-                    OAuthContextHolder.getId(jwt),
-                    OAuthContextHolder.getScope(jwt),
-                    OAuthContextHolder.isAnonymous(jwt)
-            );
-            return OAuth2TokenValidatorResult.success();
-        });
-        decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(validators));
+        // 设置令牌验证器
+        decoder.setJwtValidator(new RedisTokenValidator());
         return decoder;
     }
 

+ 19 - 8
framework-oauth/src/main/java/com/chelvc/framework/oauth/context/OAuthContextHolder.java

@@ -7,7 +7,6 @@ import java.util.Objects;
 import java.util.Set;
 import javax.servlet.http.HttpServletRequest;
 
-import com.chelvc.framework.base.context.SessionContextHolder;
 import com.chelvc.framework.common.function.Adapter;
 import com.chelvc.framework.common.util.ObjectUtils;
 import com.chelvc.framework.common.util.StringUtils;
@@ -16,6 +15,7 @@ import com.nimbusds.jwt.JWT;
 import com.nimbusds.jwt.JWTClaimsSet;
 import com.nimbusds.jwt.JWTParser;
 import lombok.NonNull;
+import org.springframework.security.oauth2.common.OAuth2AccessToken;
 import org.springframework.security.oauth2.jwt.Jwt;
 import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver;
 import org.springframework.security.oauth2.server.resource.web.DefaultBearerTokenResolver;
@@ -38,14 +38,14 @@ public final class OAuthContextHolder {
     public static final String JWT_CLAIM_SCOPE = "scope";
 
     /**
-     * Jwt是否匿名
+     * Jwt用户账号标识
      */
-    public static final String JWT_CLAIM_ANONYMOUS = "anonymous";
+    public static final String JWT_CLAIM_USERNAME = "user_name";
 
     /**
-     * Jwt用户账号标识
+     * Jwt是否匿名
      */
-    public static final String JWT_CLAIM_USER_NAME = "user_name";
+    public static final String JWT_CLAIM_ANONYMOUS = "anonymous";
 
     /**
      * Jwt用户权限标识
@@ -84,7 +84,7 @@ public final class OAuthContextHolder {
      * @return 令牌标识
      */
     public static String key(@NonNull Object id) {
-        return "token:" + SessionContextHolder.getTerminal() + ":" + id;
+        return "token:" + id;
     }
 
     /**
@@ -144,7 +144,7 @@ public final class OAuthContextHolder {
      * @return 主体身份标识
      */
     public static Long getId(Jwt jwt) {
-        return jwt == null ? null : StringUtils.ifEmpty(jwt.getClaimAsString(JWT_CLAIM_USER_NAME), Long::parseLong);
+        return jwt == null ? null : StringUtils.ifEmpty(jwt.getClaimAsString(JWT_CLAIM_USERNAME), Long::parseLong);
     }
 
     /**
@@ -155,7 +155,7 @@ public final class OAuthContextHolder {
      */
     public static Long getId(JWT jwt) {
         return getJwtClaim(jwt, claims -> StringUtils.ifEmpty(
-                claims.getStringClaim(JWT_CLAIM_USER_NAME), Long::parseLong
+                claims.getStringClaim(JWT_CLAIM_USERNAME), Long::parseLong
         ));
     }
 
@@ -203,6 +203,17 @@ public final class OAuthContextHolder {
         }), (String) null);
     }
 
+    /**
+     * 获取应用范围
+     *
+     * @param token OAuth认证令牌对象
+     * @return 应用范围
+     */
+    public static String getScope(OAuth2AccessToken token) {
+        Set<String> scopes = ObjectUtils.ifNull(token, OAuth2AccessToken::getScope);
+        return ObjectUtils.isEmpty(scopes) ? null : StringUtils.ifEmpty(scopes.iterator().next(), (String) null);
+    }
+
     /**
      * 是否是匿名用户
      *

+ 0 - 36
framework-oauth/src/main/java/com/chelvc/framework/oauth/token/RedisTokenActiveValidator.java

@@ -1,36 +0,0 @@
-package com.chelvc.framework.oauth.token;
-
-import java.util.Objects;
-
-import com.chelvc.framework.common.util.StringUtils;
-import com.chelvc.framework.oauth.context.OAuthContextHolder;
-import com.chelvc.framework.redis.context.RedisContextHolder;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
-import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
-import org.springframework.security.oauth2.core.OAuth2Error;
-import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
-import org.springframework.security.oauth2.jwt.Jwt;
-import org.springframework.stereotype.Component;
-
-/**
- * 基于Redis的令牌有效性验证实现
- *
- * @author Woody
- * @date 2024/1/30
- */
-@Component
-@ConditionalOnClass(RedisContextHolder.class)
-public class RedisTokenActiveValidator implements TokenActiveValidator {
-    @Override
-    public OAuth2TokenValidatorResult validate(Jwt jwt) {
-        String key = OAuthContextHolder.key(OAuthContextHolder.getId(jwt));
-        String token = (String) RedisContextHolder.getDefaultTemplate().opsForValue().get(key);
-        if (StringUtils.isEmpty(token)) {
-            throw new OAuth2AuthenticationException(new OAuth2Error("TOKEN_EXPIRED", "Token has expired", null));
-        } else if (!Objects.equals(token, jwt.getTokenValue())) {
-            // 判断令牌是否相同,如果不相同则标识令牌已经被重置(账号已在其他地方登录)
-            throw new OAuth2AuthenticationException(new OAuth2Error("TOKEN_REPLACED", "Token has replaced", null));
-        }
-        return OAuth2TokenValidatorResult.success();
-    }
-}

+ 14 - 1
framework-oauth/src/main/java/com/chelvc/framework/oauth/token/RedisTokenStore.java

@@ -1,10 +1,14 @@
 package com.chelvc.framework.oauth.token;
 
 import java.time.Duration;
+import java.util.Map;
 
+import com.chelvc.framework.base.context.SessionContextHolder;
 import com.chelvc.framework.oauth.context.OAuthContextHolder;
 import com.chelvc.framework.redis.context.RedisContextHolder;
+import com.google.common.collect.ImmutableMap;
 import lombok.NonNull;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.security.core.userdetails.UserDetails;
 import org.springframework.security.oauth2.common.OAuth2AccessToken;
 import org.springframework.security.oauth2.provider.OAuth2Authentication;
@@ -17,6 +21,7 @@ import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;
  * @author Woody
  * @date 2024/1/30
  */
+@Slf4j
 public class RedisTokenStore extends JwtTokenStore {
     public RedisTokenStore(@NonNull JwtAccessTokenConverter jwtAccessTokenConverter) {
         super(jwtAccessTokenConverter);
@@ -29,7 +34,15 @@ public class RedisTokenStore extends JwtTokenStore {
             principal = ((UserDetails) principal).getUsername();
         }
         String key = OAuthContextHolder.key(principal);
+        Map<String, ?> values = ImmutableMap.of(
+                SessionContextHolder.HEADER_SCOPE, String.valueOf(OAuthContextHolder.getScope(token)),
+                String.valueOf(SessionContextHolder.getTerminal()), token.getValue()
+        );
         Duration duration = RedisContextHolder.duration(token.getExpiration());
-        RedisContextHolder.getDefaultTemplate().opsForValue().set(key, token.getValue(), duration);
+        try {
+            RedisContextHolder.put(RedisContextHolder.getDefaultTemplate(), key, values, duration);
+        } catch (Exception e) {
+            log.warn("Redis token save failed: {}", e.getMessage());
+        }
     }
 }

+ 90 - 0
framework-oauth/src/main/java/com/chelvc/framework/oauth/token/RedisTokenValidator.java

@@ -0,0 +1,90 @@
+package com.chelvc.framework.oauth.token;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+
+import com.chelvc.framework.base.context.ApplicationContextHolder;
+import com.chelvc.framework.base.context.SessionContextHolder;
+import com.chelvc.framework.common.util.ObjectUtils;
+import com.chelvc.framework.common.util.StringUtils;
+import com.chelvc.framework.oauth.context.OAuthContextHolder;
+import com.chelvc.framework.redis.context.RedisContextHolder;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2TokenValidator;
+import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
+
+/**
+ * 基于Redis的令牌验证器实现
+ *
+ * @author Woody
+ * @date 2024/1/30
+ */
+@Slf4j
+public class RedisTokenValidator implements OAuth2TokenValidator<Jwt> {
+    private final JwtTimestampValidator timestampValidator = new JwtTimestampValidator();
+
+    @Override
+    public OAuth2TokenValidatorResult validate(Jwt jwt) {
+        // 尝试批量从Redis获取令牌相关信息,如果Redis读取失败则降级为只校验令牌过期时间
+        String key = OAuthContextHolder.key(OAuthContextHolder.getId(jwt));
+        Collection<Object> fields = Arrays.asList(
+                SessionContextHolder.HEADER_SCOPE,
+                String.valueOf(SessionContextHolder.getTerminal())
+        );
+        List<?> values;
+        try {
+            values = RedisContextHolder.getDefaultTemplate().opsForHash().multiGet(key, fields);
+        } catch (Exception e) {
+            log.warn("Redis token validate failed: {}", e.getMessage());
+
+            // 校验令牌是否过期
+            if (this.timestampValidator.validate(jwt).hasErrors()) {
+                throw new OAuth2AuthenticationException(new OAuth2Error(
+                        "TOKEN_EXPIRED", ApplicationContextHolder.getMessage("Token.Expired"), null
+                ));
+            }
+
+            // 初始化会话信息
+            SessionContextHolder.initializeSession(
+                    OAuthContextHolder.getId(jwt),
+                    OAuthContextHolder.getScope(jwt),
+                    OAuthContextHolder.isAnonymous(jwt)
+            );
+            return OAuth2TokenValidatorResult.success();
+        }
+
+        // 基于Redis令牌有效性校验
+        String scope = String.valueOf(ObjectUtils.size(values) > 0 ? values.get(0) : null);
+        String token = String.valueOf(ObjectUtils.size(values) > 1 ? values.get(1) : null);
+        if (StringUtils.isEmpty(token)) {
+            throw new OAuth2AuthenticationException(new OAuth2Error(
+                    "TOKEN_EXPIRED", ApplicationContextHolder.getMessage("Token.Expired"), null
+            ));
+        } else if (!Objects.equals(token, jwt.getTokenValue())) {
+            // 判断令牌是否相同,如果不相同则表示令牌已经被重置(账号已在其他地方登录)
+            throw new OAuth2AuthenticationException(new OAuth2Error(
+                    "TOKEN_CHANGED", ApplicationContextHolder.getMessage("Token.Changed"), null
+            ));
+        } else if (!Objects.equals(scope, String.valueOf(OAuthContextHolder.getScope(jwt)))) {
+            // 判断应用范围是否相同,如果不同则表示应用范围已被重置,需要刷新令牌
+            String message = ApplicationContextHolder.getMessage(
+                    "Scope.Changed", ApplicationContextHolder.getMessage(scope)
+            );
+            throw new OAuth2AuthenticationException(new OAuth2Error("SCOPE_CHANGED", message, null));
+        }
+
+        // 初始化会话信息
+        SessionContextHolder.initializeSession(
+                OAuthContextHolder.getId(jwt),
+                OAuthContextHolder.getScope(jwt),
+                OAuthContextHolder.isAnonymous(jwt)
+        );
+        return OAuth2TokenValidatorResult.success();
+    }
+}

+ 3 - 0
framework-oauth/src/main/resources/i18n/messages.properties

@@ -0,0 +1,3 @@
+Token.Expired=会话已失效,请重新登陆
+Token.Changed=您的账号已在其他设备登陆
+Scope.Changed=您的应用已切换到{0}

+ 49 - 0
framework-redis/src/main/java/com/chelvc/framework/redis/context/RedisContextHolder.java

@@ -224,6 +224,15 @@ public final class RedisContextHolder {
                     "else redis.call('SET', KEYS[1], 0) return 0 end", Long.class
     );
 
+    /**
+     * 批量设置带过期时间的hash值
+     */
+    private static final RedisScript<Boolean> HSET_WITH_DURATION_SCRIPT = new DefaultRedisScript<>(
+            "local values = {} for i = 2, #ARGV do table.insert(values, ARGV[i]) end " +
+                    "return redis.call('HSET', KEYS[1], unpack(values)) and redis.call('EXPIRE', KEYS[1], ARGV[1])",
+            Boolean.class
+    );
+
     /**
      * 新增Stream消息流脚本
      */
@@ -1063,6 +1072,46 @@ public final class RedisContextHolder {
                 initialValue, duration.getSeconds());
     }
 
+    /**
+     * 批量设置带过期时间的hash值
+     *
+     * @param key      键名称
+     * @param values   hash键/值对
+     * @param duration 有效时间
+     * @param <K>      键类型
+     * @param <V>      值类型
+     * @return true/false
+     */
+    public static <K, V> boolean put(@NonNull String key, @NonNull Map<K, V> values, @NonNull Duration duration) {
+        return put(getRedisTemplate(), key, values, duration);
+    }
+
+    /**
+     * 批量设置带过期时间的hash值
+     *
+     * @param template Redis操作模版实例
+     * @param key      键名称
+     * @param values   hash键/值对
+     * @param duration 有效时间
+     * @param <K>      键类型
+     * @param <V>      值类型
+     * @return true/false
+     */
+    public static <K, HK, HV> boolean put(@NonNull RedisTemplate<K, ?> template, @NonNull K key,
+                                          @NonNull Map<HK, HV> values, @NonNull Duration duration) {
+        if (ObjectUtils.isEmpty(values)) {
+            return false;
+        }
+        Object[] args = new Object[values.size() + 1];
+        int i = 0;
+        args[i++] = duration.getSeconds();
+        for (Map.Entry<?, ?> entry : values.entrySet()) {
+            args[i++] = entry.getKey();
+            args[i++] = entry.getValue();
+        }
+        return Boolean.TRUE.equals(template.execute(HSET_WITH_DURATION_SCRIPT, Collections.singletonList(key), args));
+    }
+
     /**
      * 数字自增1,如果键不存在则对值初始化
      *

+ 4 - 1
framework-security/src/main/java/com/chelvc/framework/security/interceptor/SecurityValidateInterceptor.java

@@ -4,6 +4,7 @@ import java.util.Objects;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
+import com.chelvc.framework.base.context.ApplicationContextHolder;
 import com.chelvc.framework.base.context.LoggingContextHolder;
 import com.chelvc.framework.base.context.SessionContextHolder;
 import com.chelvc.framework.base.util.HttpUtils;
@@ -73,7 +74,9 @@ public class SecurityValidateInterceptor implements HandlerInterceptor, WebMvcCo
             // 请求时间戳校验
             long now = System.currentTimeMillis(), duration = this.properties.getDuration();
             if (duration > 0 && (session.getTimestamp() > now || now - session.getTimestamp() > duration)) {
-                throw new FrameworkException(HttpStatus.FORBIDDEN.name(), null, "请校准系统时间");
+                throw new FrameworkException(
+                        HttpStatus.FORBIDDEN.name(), null, ApplicationContextHolder.getMessage("Time.Deviated")
+                );
             }
         }
 

+ 1 - 0
framework-security/src/main/resources/i18n/messages.properties

@@ -0,0 +1 @@
+Time.Deviated=请校准系统时间

+ 7 - 0
framework-sms/src/main/java/com/chelvc/framework/sms/TemplateSmsHandler.java

@@ -9,6 +9,13 @@ import java.util.Map;
  * @date 2024/1/30
  */
 public interface TemplateSmsHandler {
+    /**
+     * 发送短信验证码
+     *
+     * @param captcha 验证码
+     */
+    void send(Captcha captcha);
+
     /**
      * 发送短信验证码
      *

+ 5 - 5
framework-sms/src/main/java/com/chelvc/framework/sms/config/SmsProperties.java

@@ -62,6 +62,11 @@ public class SmsProperties {
          */
         private String region;
 
+        /**
+         * 验证码模版
+         */
+        private String template;
+
         /**
          * 短信签名
          */
@@ -78,11 +83,6 @@ public class SmsProperties {
      */
     @Data
     public static class Captcha {
-        /**
-         * 验证码模版
-         */
-        private String template;
-
         /**
          * 验证码长度
          */

+ 9 - 1
framework-sms/src/main/java/com/chelvc/framework/sms/support/AliyunSmsHandler.java

@@ -8,6 +8,7 @@ import com.aliyun.dysmsapi20170525.models.SendSmsRequest;
 import com.aliyun.dysmsapi20170525.models.SendSmsResponse;
 import com.aliyun.dysmsapi20170525.models.SendSmsResponseBody;
 import com.aliyun.teaopenapi.models.Config;
+import com.chelvc.framework.base.context.ApplicationContextHolder;
 import com.chelvc.framework.base.context.JacksonContextHolder;
 import com.chelvc.framework.common.exception.ResourceUnavailableException;
 import com.chelvc.framework.sms.Captcha;
@@ -26,6 +27,7 @@ import lombok.extern.slf4j.Slf4j;
 @Slf4j
 public class AliyunSmsHandler implements TemplateSmsHandler {
     private final Client client;
+    private final String template;
     private final String signature;
 
     public AliyunSmsHandler(@NonNull SmsProperties.Client properties) {
@@ -38,9 +40,15 @@ public class AliyunSmsHandler implements TemplateSmsHandler {
         } catch (Exception e) {
             throw new RuntimeException(e);
         }
+        this.template = properties.getTemplate();
         this.signature = properties.getSignature();
     }
 
+    @Override
+    public void send(@NonNull Captcha captcha) {
+        this.send(this.template, captcha);
+    }
+
     @Override
     public void send(@NonNull String template, @NonNull Captcha captcha) {
         this.send(captcha.getMobile(), template, ImmutableMap.of("code", captcha.getCode()));
@@ -67,7 +75,7 @@ public class AliyunSmsHandler implements TemplateSmsHandler {
             if (Objects.equals(body.getCode(), "isv.DAY_LIMIT_CONTROL")
                     || Objects.equals(body.getCode(), "isv.MONTH_LIMIT_CONTROL")
                     || Objects.equals(body.getCode(), "isv.BUSINESS_LIMIT_CONTROL")) {
-                throw new ResourceUnavailableException("短信发送次数已达上限");
+                throw new ResourceUnavailableException(ApplicationContextHolder.getMessage("SMS.Count.Limit"));
             }
             throw new ResourceUnavailableException(body.getMessage());
         }

+ 3 - 2
framework-sms/src/main/java/com/chelvc/framework/sms/support/DefaultCaptchaSmsHandler.java

@@ -84,7 +84,8 @@ public class DefaultCaptchaSmsHandler implements CaptchaSmsHandler {
         // 短信验证码发送频率限制
         String lock = "sms:captcha:interval:" + mobile;
         String secret = RedisContextHolder.tryLock(lock, Duration.ofSeconds(this.properties.getInterval()));
-        AssertUtils.available(StringUtils.notEmpty(secret), () -> "短信发送过于频繁,请稍后再试");
+        AssertUtils.available(StringUtils.notEmpty(secret),
+                () -> ApplicationContextHolder.getMessage("SMS.Frequency.Limit"));
 
         // 如果目标手机号属于白名单手机号则将手机号后{{length}}位作为验证码,否则获取真实的验证码
         try {
@@ -94,7 +95,7 @@ public class DefaultCaptchaSmsHandler implements CaptchaSmsHandler {
             String code = whitelisted ? mobile.substring(mobile.length() - length) : this.random(length);
             Captcha captcha = Captcha.builder().id(id).code(code).mobile(mobile).build();
             if (!whitelisted) {
-                Objects.requireNonNull(this.handler).send(this.properties.getTemplate(), captcha);
+                Objects.requireNonNull(this.handler).send(captcha);
             }
             RedisContextHolder.getDefaultTemplate().opsForValue()
                     .set(key, captcha, this.properties.getExpiration(), TimeUnit.SECONDS);

+ 5 - 0
framework-sms/src/main/java/com/chelvc/framework/sms/support/DelegatingTemplateSmsHandler.java

@@ -50,6 +50,11 @@ public class DelegatingTemplateSmsHandler implements TemplateSmsHandler {
         }
     }
 
+    @Override
+    public void send(@NonNull Captcha captcha) {
+        this.execute(handler -> handler.send(captcha));
+    }
+
     @Override
     public void send(@NonNull String template, @NonNull Captcha captcha) {
         this.execute(handler -> handler.send(template, captcha));

+ 9 - 1
framework-sms/src/main/java/com/chelvc/framework/sms/support/TencentSmsHandler.java

@@ -2,6 +2,7 @@ package com.chelvc.framework.sms.support;
 
 import java.util.Map;
 
+import com.chelvc.framework.base.context.ApplicationContextHolder;
 import com.chelvc.framework.common.exception.ResourceUnavailableException;
 import com.chelvc.framework.sms.Captcha;
 import com.chelvc.framework.sms.TemplateSmsHandler;
@@ -22,15 +23,22 @@ import lombok.extern.slf4j.Slf4j;
 public class TencentSmsHandler implements TemplateSmsHandler {
     private final SmsSingleSender sender;
     private final String region;
+    private final String template;
     private final String signature;
 
     public TencentSmsHandler(@NonNull SmsProperties.Client properties) {
         int appid = Integer.parseInt(properties.getId());
         this.sender = new SmsSingleSender(appid, properties.getSecret(), new PoolingHTTPClient());
         this.region = properties.getRegion();
+        this.template = properties.getTemplate();
         this.signature = properties.getSignature();
     }
 
+    @Override
+    public void send(@NonNull Captcha captcha) {
+        this.send(this.template, captcha);
+    }
+
     @Override
     public void send(@NonNull String template, @NonNull Captcha captcha) {
         this.send(captcha.getMobile(), template, captcha.getCode());
@@ -47,7 +55,7 @@ public class TencentSmsHandler implements TemplateSmsHandler {
         }
         if (result.result != 0) {
             if (result.result == 1023 || result.result == 1025) {
-                throw new ResourceUnavailableException("短信发送次数已达上限");
+                throw new ResourceUnavailableException(ApplicationContextHolder.getMessage("SMS.Count.Limit"));
             }
             throw new ResourceUnavailableException(result.errMsg);
         }

+ 2 - 0
framework-sms/src/main/resources/i18n/messages.properties

@@ -0,0 +1,2 @@
+SMS.Count.Limit=短信发送次数已达上限
+SMS.Frequency.Limit=短信发送过于频繁,请稍后再试