Commit 94e075e9 by xieshaohua

inti 海南农商单点

parent c51bc65a
......@@ -52,6 +52,16 @@
<artifactId>jasypt-spring-boot-starter</artifactId>
<version>3.0.3</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.28</version>
</dependency>
</dependencies>
<build>
......
......@@ -4,15 +4,20 @@ import com.keymobile.authservice.component.SecurityConfig;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.FilterType;
import org.springframework.context.annotation.PropertySource;
@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
@ComponentScan(basePackages = {"com.keymobile.sso",
"com.keymobile.config.logging", "com.keymobile.config.naming",
"com.keymobile.config.redisclient", "com.keymobile.authservice.component"}, excludeFilters = {
"com.keymobile.config.logging",
"com.keymobile.config.naming",
"com.keymobile.config.redisclient",
"com.keymobile.config.feignclient",
"com.keymobile.authservice.component"}, excludeFilters = {
@ComponentScan.Filter(type= FilterType.ASSIGNABLE_TYPE, value= SecurityConfig.class)
})
@PropertySource(value = "classpath:/application.yml")
......
package com.keymobile.sso.api;
import com.keymobile.sso.service.PortalAuthService;
import com.keymobile.sso.utils.ResponseUtil;
import com.keymobile.sso.vo.PortalAuthReq;
import com.keymobile.sso.vo.ResponseVO;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 门户免登录验签。
*/
@RestController
@RequestMapping("/portalAuth")
@Slf4j
public class PortalAuthController {
@Autowired
private PortalAuthService portalAuthService;
@RequestMapping(value = "/verifyToken", method = {RequestMethod.POST, RequestMethod.GET})
public ResponseVO<?> verifyToken(@RequestParam(required = false) String sign,
HttpServletRequest request,
HttpServletResponse response) {
try {
return portalAuthService.verifyAndLogin(sign, request, response);
} catch (Exception e) {
log.error("门户免登录验签异常:{}", e.getMessage(), e);
return ResponseUtil.error(e.getMessage() != null ? e.getMessage() : "门户免登录验签异常");
}
}
}
package com.keymobile.sso.conf;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.*;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.session.data.redis.config.ConfigureRedisAction;
/**
* redis配置.
* @author mahx
* @version 1.0
* @date 2019/12/27 16:55
*/
@Configuration
public class RedisConfig {
/**
* 实例化 RedisTemplate 对象.
*
* @return redisTemplate
*/
@Bean
public RedisTemplate<String, Object> functionDomainRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
initDomainRedisTemplate(redisTemplate, redisConnectionFactory);
return redisTemplate;
}
/**
* 设置数据存入 redis 的序列化方式.
*
* @param redisTemplate redisTemplate
* @param factory RedisConnectionFactory
*/
private void initDomainRedisTemplate(RedisTemplate<String, Object> redisTemplate,
RedisConnectionFactory factory) {
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
redisTemplate.setConnectionFactory(factory);
}
/**
* 实例化 HashOperations 对象,可以使用 Hash 类型操作.
*
* @param redisTemplate redisTemplate
* @return HashOperations
*/
@Bean
public HashOperations<String, String, Object> hashOperations(
RedisTemplate<String, Object> redisTemplate) {
return redisTemplate.opsForHash();
}
/**
* 实例化 ValueOperations 对象,可以使用 String 操作.
*
* @param redisTemplate redisTemplate
* @return ValueOperations
*/
@Bean
public ValueOperations<String, Object> valueOperations(
RedisTemplate<String, Object> redisTemplate) {
return redisTemplate.opsForValue();
}
/**
* 实例化 ListOperations 对象,可以使用 List 操作.
*
* @param redisTemplate redisTemplate
* @return ListOperations
*/
@Bean
public ListOperations<String, Object> listOperations(
RedisTemplate<String, Object> redisTemplate) {
return redisTemplate.opsForList();
}
/**
* 实例化 SetOperations 对象,可以使用 Set 操作.
*
* @param redisTemplate redisTemplate
* @return SetOperations
*/
@Bean
public SetOperations<String, Object> setOperations(
RedisTemplate<String, Object> redisTemplate) {
return redisTemplate.opsForSet();
}
/**
* 实例化 ZSetOperations 对象,可以使用 ZSet 操作.
*
* @param redisTemplate redisTemplate
* @return ZSetOperations
*/
@Bean
public ZSetOperations<String, Object> zsetOperations(
RedisTemplate<String, Object> redisTemplate) {
return redisTemplate.opsForZSet();
}
}
package com.keymobile.sso.conf;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.stereotype.Component;
/**
* 单点登录配置(单一门户对接,无需多应用列表)。
*/
@Data
@Component
@RefreshScope
@ConfigurationProperties(prefix = "sso")
public class SsoProperties {
/**
* 是否启用 IP 白名单校验,默认开启
*/
private boolean ipWhitelistEnabled = false;
/**
* 应用标识(用于校验票据中的 appId,可选)
*/
private String appId;
/**
* 门户服务器 IP(支持多个,逗号分隔)
*/
private String portalIp;
/**
* SM4 加密密钥(32 位十六进制字符串)
*/
private String sm4Key;
/**
* 票据有效期(秒),默认 300 秒
*/
private long expireSeconds = 300;
/**
* 是否启用防重放校验
*/
private boolean replayProtection = true;
}
......@@ -42,6 +42,7 @@ public class SsoSecurityConfig {
@Bean
protected SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests((request) -> {
request.requestMatchers("/portalAuth/**").permitAll();
request.anyRequest().authenticated();
});
http.csrf((httpSecurityCsrfConfigurer) -> {
......
package com.keymobile.sso.exception;
/**
* 工具类通用异常(非受检),用于加解密等工具方法中包装底层错误。
*/
public class PortalLoginException extends RuntimeException {
private static final long serialVersionUID = 1L;
private String cnMessage;
public PortalLoginException() {
super();
}
public PortalLoginException(String message) {
super(message);
this.cnMessage = message;
}
public PortalLoginException(String message, String cnMessage) {
super(message);
this.cnMessage = cnMessage;
}
public PortalLoginException(String message, Throwable cause) {
super(message, cause);
this.cnMessage = message;
}
public PortalLoginException(String message, String cnMessage, Throwable cause) {
super(message, cause);
this.cnMessage = cnMessage;
}
public PortalLoginException(Throwable cause) {
super(cause);
this.cnMessage = cause != null ? cause.getMessage() : null;
}
public String getCnMessage() {
return cnMessage;
}
}
......@@ -28,6 +28,12 @@ public class RestExceptionHandler extends ResponseEntityExceptionHandler {
return buildResponseEntity(apiError);
}
@ExceptionHandler(UtilException.class)
protected ResponseEntity<Object> handleUtilException(UtilException ex, WebRequest request) {
ApiError apiError = new ApiError(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage(), ex.getCnMessage(), ex);
return buildResponseEntity(apiError);
}
@ExceptionHandler(Exception.class)
protected ResponseEntity<Object> handlException(Exception ex, WebRequest request) {
ApiError apiError;
......
package com.keymobile.sso.exception;
/**
* 工具类通用异常(非受检),用于加解密等工具方法中包装底层错误。
*/
public class UtilException extends RuntimeException {
private static final long serialVersionUID = 1L;
private String cnMessage;
public UtilException() {
super();
}
public UtilException(String message) {
super(message);
this.cnMessage = message;
}
public UtilException(String message, String cnMessage) {
super(message);
this.cnMessage = cnMessage;
}
public UtilException(String message, Throwable cause) {
super(message, cause);
this.cnMessage = message;
}
public UtilException(String message, String cnMessage, Throwable cause) {
super(message, cause);
this.cnMessage = cnMessage;
}
public UtilException(Throwable cause) {
super(cause);
this.cnMessage = cause != null ? cause.getMessage() : null;
}
public String getCnMessage() {
return cnMessage;
}
}
package com.keymobile.sso.service;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@FeignClient(value = "authService")
public interface AuthService {
@RequestMapping(value = "/users/findByName")
List<Map<String, Object>> getUserByName(@RequestParam(value = "match") String match);
@PostMapping(value = "/users")
Map<String, Object> addUser(@RequestBody Map<String, Object> user);
@PostMapping(value = "/users/{userId}")
Map<String, Object> updateUser(@PathVariable(value = "userId") Long userId, @RequestBody Map<String, Object> user);
@GetMapping("/users/{userId}")
Map<String, Object> getUserById(@PathVariable("userId") Long userId);
@GetMapping("/users/getByName")
Map<String, Object> getByName(@RequestParam("name") String name);
}
package com.keymobile.sso.service;
import cn.hutool.core.date.TimeInterval;
import cn.hutool.json.JSONUtil;
import com.keymobile.sso.conf.SsoProperties;
import com.keymobile.sso.exception.UtilException;
import com.keymobile.sso.logging.LogConstants;
import com.keymobile.sso.logging.LogManager;
import com.keymobile.sso.utils.*;
import com.keymobile.sso.vo.PortalAuthReq;
import com.keymobile.sso.vo.ResponseVO;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.slf4j.MDC;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.provisioning.UserDetailsManager;
import org.springframework.security.web.authentication.WebAuthenticationDetails;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
public class PortalAuthService {
@Autowired
private SsoProperties ssoProperties;
@Autowired
private RedisUtil redisUtil;
@Autowired
private AuthService authService;
@Autowired
private UserDetailsManager customizedUserDetailService;
@Value("${self.login.redirectUrl:http://139.198.127.54:18079/data-center/view/index}")
private String ssoRedirectUrl;
private static final String REPLAY_KEY_PREFIX = "sso:auth:replay:";
/**
* 门户免登录验签。
* 成功:建立会话并重定向到门户页面;失败:返回错误响应。
*
* @return 失败时返回错误信息;成功重定向后返回 null
*/
public ResponseVO<?> verifyAndLogin(String sign, HttpServletRequest request, HttpServletResponse response)
throws IOException {
TimeInterval timer = cn.hutool.core.date.DateUtil.timer();
log.info("sso请求参数:{}", sign);
// ① 参数校验
if (StringUtils.isBlank(sign)) {
return ResponseUtil.error("请求参数不完整");
}
log.info("单点配置:{}", JSONUtil.toJsonStr(ssoProperties));
if (StringUtils.isBlank(ssoProperties.getSm4Key())) {
return ResponseUtil.error("单点登录未配置");
}
String requestIp = IpUtil.getIp();
log.info("请求ip:{}", requestIp);
// ② IP 白名单校验(可通过 sso.ipWhitelistEnabled 关闭)
if (ssoProperties.isIpWhitelistEnabled()) {
if (!checkIpWhitelist(requestIp, ssoProperties.getPortalIp())) {
log.warn("非法请求来源IP:{},允许IP:{}", requestIp, ssoProperties.getPortalIp());
return ResponseUtil.error("非法请求来源IP");
}
} else {
log.info("IP白名单校验已关闭,跳过校验");
}
// ③ SM4 解密
String plainText;
try {
plainText = Sm4CbcUtil.decrypt(sign, ssoProperties.getSm4Key());
log.info("票据:{}", plainText);
} catch (Exception e) {
log.error("票据解密失败:{}", e.getMessage());
try {
String urlDecoder = URLDecoder.decode(sign, StandardCharsets.UTF_8);
log.info("url解码签名:{}", urlDecoder);
plainText = Sm4CbcUtil.decrypt(urlDecoder, ssoProperties.getSm4Key());
log.info("url解码票据:{}", plainText);
} catch (Exception ex) {
log.error("url解码票据解密失败:{}", e.getMessage());
return ResponseUtil.error("票据解密失败");
}
}
// ④ 票据完整性校验(明文:emplyId,timestamp[,appId])
String[] parts = plainText.split("\\|");
if (parts.length < 4) {
return ResponseUtil.error("票据格式错误");
}
String emplyId = parts[3];
String timestampStr = parts[1];
String portalIp = parts[0];
// 若票据含 appId,则与配置中的 appId 比对
if (parts.length >= 3 && StringUtils.isNotBlank(ssoProperties.getAppId())) {
String appIdInToken = parts[2];
if (!ssoProperties.getAppId().equals(appIdInToken)) {
log.warn("应用标识不一致: 配置={}, 票据={}", ssoProperties.getAppId(), appIdInToken);
return ResponseUtil.error("应用标识不一致");
}
}
if (!checkIpWhitelist(portalIp, ssoProperties.getPortalIp())) {
log.warn("非法请求来源IP:{},允许IP:{}", requestIp, ssoProperties.getPortalIp());
return ResponseUtil.error("非法请求来源IP");
}
// 时间戳时效校验
long timestamp;
try {
timestamp = Long.parseLong(timestampStr);
} catch (NumberFormatException e) {
return ResponseUtil.error("时间戳格式错误");
}
long now = System.currentTimeMillis();
long diffSeconds = (now - timestamp) / 1000;
if (diffSeconds > ssoProperties.getExpireSeconds()) {
log.warn("票据已过期: 时间差{}秒, 有效期{}秒", diffSeconds, ssoProperties.getExpireSeconds());
return ResponseUtil.error("票据已过期");
}
if (diffSeconds < -5) {
return ResponseUtil.error("票据时间异常");
}
// 防重放校验
if (ssoProperties.isReplayProtection()) {
String replayKey = REPLAY_KEY_PREFIX + emplyId + ":" + timestampStr;
boolean notUsed = redisUtil.setIfAbsent(replayKey, "1",
ssoProperties.getExpireSeconds(), TimeUnit.SECONDS);
if (!notUsed) {
log.warn("票据已被使用: emplyId={}, timestamp={}", emplyId, timestampStr);
return ResponseUtil.error("票据已被使用");
}
}
log.info("门户免登录验签通过: emplyId={}, 耗时:{}ms", emplyId, timer.intervalRestart());
// ⑤ 用户身份校验 + 建立会话,成功后重定向
return ssoLogin(emplyId, request, response);
}
private ResponseVO<?> ssoLogin(String userName, HttpServletRequest request, HttpServletResponse response)
throws IOException {
Map<String, Object> user = authService.getByName(userName);
if (user == null) {
return ResponseUtil.error(userName + "员工编号不存在");
}
UserDetails userDetails = customizedUserDetailService.loadUserByUsername(userName);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, userDetails.getPassword(), userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
HttpSession session = request.getSession(true);
session.setAttribute("SPRING_SECURITY_CONTEXT", SecurityContextHolder.getContext());
log.info("单点登录用户:{}", userName);
MDC.put("user", userName);
MDC.put("session", session.getId());
LogManager.logInfo(LogConstants.CTX_AUDIT, "登录");
response.sendRedirect(ssoRedirectUrl);
return null;
}
private boolean checkIpWhitelist(String requestIp, String allowedIps) {
if (StringUtils.isBlank(allowedIps)) {
return false;
}
String[] ipArray = allowedIps.split(",");
for (String ip : ipArray) {
if (ip.trim().equals(requestIp)) {
return true;
}
}
return false;
}
}
package com.keymobile.sso.utils;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
/**
* IP工具类,获取客户端真实IP
*/
public class IpUtil {
/**
* 直接调用获取当前请求客户端IP(无需传request)
* @return 客户端IP地址
*/
public static String getIp() {
HttpServletRequest request = getHttpServletRequest();
if (request == null) {
return "unknown";
}
return getIpAddr(request);
}
/**
* 根据request获取真实IP核心方法
*/
public static String getIpAddr(HttpServletRequest request) {
// 1. 优先取反向代理转发IP
String xForwardedFor = request.getHeader("X-Forwarded-For");
if (xForwardedFor != null && !xForwardedFor.isEmpty() && !"unknown".equalsIgnoreCase(xForwardedFor)) {
// 多次反向代理后会有多个ip,第一个为真实ip
String[] ipArray = xForwardedFor.split(",");
for (String ip : ipArray) {
String realIp = ip.trim();
if (!realIp.isEmpty() && !"unknown".equalsIgnoreCase(realIp)) {
return realIp;
}
}
}
// 2. Nginx代理真实IP
String xRealIp = request.getHeader("X-Real-IP");
if (xRealIp != null && !xRealIp.isEmpty() && !"unknown".equalsIgnoreCase(xRealIp)) {
return xRealIp;
}
// 3. Apache代理
String proxyClientIp = request.getHeader("Proxy-Client-IP");
if (proxyClientIp != null && !proxyClientIp.isEmpty() && !"unknown".equalsIgnoreCase(proxyClientIp)) {
return proxyClientIp;
}
// 4. WL代理
String wlProxyClientIp = request.getHeader("WL-Proxy-Client-IP");
if (wlProxyClientIp != null && !wlProxyClientIp.isEmpty() && !"unknown".equalsIgnoreCase(wlProxyClientIp)) {
return wlProxyClientIp;
}
// 5. 无代理,直接取原始请求地址
return request.getRemoteAddr();
}
/**
* 从Spring上下文获取HttpServletRequest
*/
private static HttpServletRequest getHttpServletRequest() {
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
if (requestAttributes == null) {
return null;
}
return ((ServletRequestAttributes) requestAttributes).getRequest();
}
/**
* 判断是否为内网IP
*/
public static boolean isInnerIp(String ip) {
if (ip == null || ip.isEmpty()) {
return false;
}
// 127.0.0.1
if (ip.startsWith("127.")) {
return true;
}
// 10.0.0.0/8
if (ip.startsWith("10.")) {
return true;
}
// 172.16.0.0/12
if (ip.startsWith("172.")) {
String[] split = ip.split("\\.");
if (split.length >= 2) {
try {
int second = Integer.parseInt(split[1]);
if (second >= 16 && second <= 31) {
return true;
}
} catch (NumberFormatException ignored) {
}
}
}
// 192.168.0.0/16
return ip.startsWith("192.168.");
}
}
package com.keymobile.sso.utils;
import cn.hutool.core.codec.Base64Encoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Map;
/**
* @author xiesh
* @version 1.0.0
* @date 2026/8/4
* @desc
*/
public class PortalTokenGenerator {
/**
* 生成免登录加密票据
* @param emplyId 员工工号
* @param appId 应用标识
* @param sm4Key SM4密钥(32位十六进制字符串)
* @return Base64编码的加密票据
*/
public static String generateToken(String emplyId, String appId, String sm4Key) {
// 1. 生成时间戳(毫秒)
String timestamp = String.valueOf(System.currentTimeMillis());
// 2. 拼接明文:工号,时间戳,应用标识
String plaintext = emplyId + "," + timestamp + "," + appId;
// 3. SM4-CBC 加密
String encrypt = Sm4CbcUtil.encrypt(plaintext, sm4Key);
return encrypt;
}
/**
* 生成 iframe 嵌入URL
*/
public static String generateIframeUrl(String autoLoginUrl, String token,
String targetPath, Map<String, String> extraParams) {
StringBuilder url = new StringBuilder(autoLoginUrl);
url.append("?code=").append(URLEncoder.encode(token, StandardCharsets.UTF_8));
url.append("&path=").append(targetPath);
if (extraParams != null) {
for (Map.Entry<String, String> entry : extraParams.entrySet()) {
url.append("&").append(entry.getKey()).append("=")
.append(URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8));
}
}
return url.toString();
}
public static void main(String[] args) {
String test1 = generateToken("test1","portal_asset","703AF86205DB25E6999CEAB2859D0FA1");
System.out.println(test1);
String test2 = Sm4CbcUtil.decrypt(test1,"703AF86205DB25E6999CEAB2859D0FA1");
System.out.println(test2);
}
}
\ No newline at end of file
package com.keymobile.sso.utils;
import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import java.util.*;
import java.util.concurrent.TimeUnit;
@Component
public class RedisUtil {
private static final Long SUCCESS = 1L;
private static final String EMPTY_MSG = " empty key";
Logger logger = LoggerFactory.getLogger(RedisUtil.class);
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 普通缓存获取
*
* @param key 键
* @return 值
*/
public Object get(String key) {
return key == null ? null : redisTemplate.opsForValue().get(key);
}
/**
* 获取所有缓存
*
*/
public Map<String, Object> getAll(String pattern) {
Map<String, Object> result = new HashMap<>();
//获取所有key
Set<String> keys = redisTemplate.keys(pattern);
for (String key : keys) {
result.put(key, get(key));
}
return result;
}
/**
* setIfAbsent:key不存在才设置,同时设置过期时间
* 对应你业务代码:redisUtil.setIfAbsent(key, val, expire, unit)
* @param key 键
* @param value 值
* @param expire 过期时长
* @param timeUnit 时间单位
* @return true=设置成功(key不存在) false=key已存在
*/
public Boolean setIfAbsent(String key, Object value, long expire, TimeUnit timeUnit) {
ValueOperations<String, Object> ops = redisTemplate.opsForValue();
// setIfAbsent + 过期时间 原子操作(推荐用于防重复提交、分布式锁)
return ops.setIfAbsent(key, value, expire, timeUnit);
}
/**
* 普通缓存放入
*
* @param key 键
* @param value 值
* @return true成功 false失败
*/
public boolean set(String key, Object value) {
try {
redisTemplate.opsForValue().set(key, value);
return true;
} catch (Exception e) {
logger.error("set value error:", e);
return false;
}
}
/**
* 普通缓存放入并设置时间
*
* @param key 键
* @param value 值
* @param time 时间(秒) time要大于0 如果time小于等于0 将设置无限期
* @return true成功 false 失败
*/
public boolean set(String key, Object value, long time) {
try {
if (time > 0) {
redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);
} else {
set(key, value);
}
return true;
} catch (Exception e) {
logger.error("set value with expire time error:", e);
return false;
}
}
/**
* 删除建
*/
public Long del(String... key) {
if (key != null && key.length > 0) {
if (key.length == 1) {
return Boolean.TRUE.equals(redisTemplate.delete(key[0])) ? 1L : 0L;
} else {
return redisTemplate.delete(Arrays.stream(key).toList());
}
}
return 0L;
}
/**
* 删除建
*/
public Long delLike(String key) {
Set<String> keys = redisTemplate.keys(key + "*");
if (!CollectionUtils.isEmpty(keys)) {
return redisTemplate.delete(keys);
}
return 0L;
}
/**
* 递增
*
* @param key 键
* @param delta 增幅
* @return
*/
public Long incr(String key, Long delta) {
if (delta < 0) {
logger.error("递增幅度必须大于0");
}
return redisTemplate.opsForValue().increment(key, delta);
}
/**
* 递减
*/
public Long decr(String key, Long delta) {
if (delta < 0) {
logger.error("幅度必须大于0");
}
return redisTemplate.opsForValue().increment(key, -delta);
}
/**
* 获取锁
*
* @param lockKey
* @param value
* @param expireTime:单位-秒
* @return
*/
public boolean getLock(String lockKey, Object value, int expireTime) {
try {
String script = "if (redis.call('exists',KEYS[1]) == 0) then redis.call('setNx',KEYS[1],ARGV[1]) return redis.call('expire',KEYS[1],ARGV[2]) else return 0 end";
RedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
Object result = redisTemplate.execute(redisScript, Collections.singletonList(lockKey), value, expireTime);
if (SUCCESS.equals(result)) {
return true;
}
} catch (Exception e) {
logger.error("get redis lock error:", e);
}
return false;
}
/**
* 释放锁
*
* @param lockKey
* @param value
* @return
*/
public boolean releaseLock(String lockKey, Object value) {
try {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
RedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
Object result = redisTemplate.execute(redisScript, Collections.singletonList(lockKey), value);
if (SUCCESS.equals(result)) {
return true;
}
} catch (Exception e) {
logger.error("release redis lock key:" + lockKey + " error:", e);
}
return false;
}
public void sendTopicMessage(String topic, String messageJson) {
redisTemplate.convertAndSend(topic, messageJson);
}
public Long sAdd(String key, String... values) {
if (StringUtils.isBlank(key)) {
logger.error(EMPTY_MSG);
return 0L;
}
return redisTemplate.opsForSet().add(key, values);
}
public Long sRemove(String key, String... values) {
if (StringUtils.isBlank(key)) {
logger.error(EMPTY_MSG);
return 0L;
}
return redisTemplate.opsForSet().remove(key, values);
}
public Boolean sIsMember(String key, Object value) {
if (StringUtils.isBlank(key)) {
logger.error(EMPTY_MSG);
return false;
}
return redisTemplate.opsForSet().isMember(key, value);
}
public Boolean hasKey(String key) {
return redisTemplate.hasKey(key);
}
public Long getExpire(String key) {
if (StringUtils.isBlank(key)) {
return null;
}
return redisTemplate.getExpire(key);
}
public Boolean expire(String key, int time) {
if (StringUtils.isBlank(key)) {
return false;
}
return redisTemplate.expire(key, time, TimeUnit.SECONDS);
}
}
package com.keymobile.sso.utils;
import com.keymobile.sso.vo.ResponseVO;
/**
* 统一响应构造工具。
* <pre>
* 成功:{"code":"200","message":"成功","data":{...}}
* 失败:{"code":"500","message":"票据已过期"}
* </pre>
*/
public final class ResponseUtil {
public static final String CODE_SUCCESS = "200";
public static final String CODE_ERROR = "500";
public static final String MSG_SUCCESS = "成功";
private ResponseUtil() {
}
/**
* 成功响应(无 data)
*/
public static <T> ResponseVO<T> success() {
return new ResponseVO<>(CODE_SUCCESS, MSG_SUCCESS);
}
/**
* 成功响应(带 data)
*/
public static <T> ResponseVO<T> success(T data) {
return new ResponseVO<>(CODE_SUCCESS, MSG_SUCCESS, data);
}
/**
* 成功响应(自定义 message + data)
*/
public static <T> ResponseVO<T> success(String message, T data) {
return new ResponseVO<>(CODE_SUCCESS, message, data);
}
/**
* 失败响应(默认 code=500)
*/
public static <T> ResponseVO<T> error(String message) {
return new ResponseVO<>(CODE_ERROR, message);
}
/**
* 失败响应(自定义 code)
*/
public static <T> ResponseVO<T> error(String code, String message) {
return new ResponseVO<>(code, message);
}
/**
* 失败响应(带 data)
*/
public static <T> ResponseVO<T> error(String code, String message, T data) {
return new ResponseVO<>(code, message, data);
}
}
package com.keymobile.sso.utils;
import org.bouncycastle.crypto.engines.SM4Engine;
import org.bouncycastle.crypto.modes.CBCBlockCipher;
import org.bouncycastle.crypto.paddings.PKCS7Padding;
import org.bouncycastle.crypto.paddings.PaddedBufferedBlockCipher;
import org.bouncycastle.crypto.params.KeyParameter;
import org.bouncycastle.crypto.params.ParametersWithIV;
import org.springframework.util.Base64Utils;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Base64;
/**
* SM4 国密对称加密工具类
* 支持 SM4-CBC 模式,PKCS7 填充
*/
public class SM4Util {
private static final int BLOCK_SIZE = 16; // SM4 块大小 16 字节
/**
* SM4-CBC 加密
* @param keyHex 32位十六进制密钥字符串
* @param plaintext 明文字节数组
* @return 密文字节数组
*/
public static byte[] encryptCBC(String keyHex, byte[] plaintext) {
byte[] key = hexStringToBytes(keyHex);
byte[] iv = new byte[BLOCK_SIZE]; // IV 全零(与门户侧约定)
return encrypt(plaintext, key, iv);
}
/**
* SM4-CBC 解密
* @param keyHex 32位十六进制密钥字符串
* @param ciphertext 密文字节数组
* @return 明文字节数组
*/
public static byte[] decryptCBC(String keyHex, byte[] ciphertext) {
byte[] key = hexStringToBytes(keyHex);
byte[] iv = new byte[BLOCK_SIZE]; // IV 全零(与门户侧约定)
return decrypt(ciphertext, key, iv);
}
private static byte[] encrypt(byte[] plaintext, byte[] key, byte[] iv) {
try {
PaddedBufferedBlockCipher cipher = new PaddedBufferedBlockCipher(
new CBCBlockCipher(new SM4Engine()), new PKCS7Padding());
cipher.init(true, new ParametersWithIV(new KeyParameter(key), iv));
byte[] output = new byte[cipher.getOutputSize(plaintext.length)];
int len = cipher.processBytes(plaintext, 0, plaintext.length, output, 0);
len += cipher.doFinal(output, len);
return Arrays.copyOf(output, len);
} catch (Exception e) {
throw new RuntimeException("SM4加密失败", e);
}
}
private static byte[] decrypt(byte[] ciphertext, byte[] key, byte[] iv) {
try {
PaddedBufferedBlockCipher cipher = new PaddedBufferedBlockCipher(
new CBCBlockCipher(new SM4Engine()), new PKCS7Padding());
cipher.init(false, new ParametersWithIV(new KeyParameter(key), iv));
byte[] output = new byte[cipher.getOutputSize(ciphertext.length)];
int len = cipher.processBytes(ciphertext, 0, ciphertext.length, output, 0);
len += cipher.doFinal(output, len);
return Arrays.copyOf(output, len);
} catch (Exception e) {
throw new RuntimeException("SM4解密失败", e);
}
}
private static byte[] hexStringToBytes(String hexString) {
int len = hexString.length();
byte[] data = new byte[len / 2];
for (int i = 0; i < len; i += 2) {
data[i / 2] = (byte) ((Character.digit(hexString.charAt(i), 16) << 4)
+ Character.digit(hexString.charAt(i + 1), 16));
}
return data;
}
public static void main(String[]arg){
//
// String plainText = null;
// try {
// plainText = new String(SM4Util.decryptCBC("703AF86205DB25E6999CEAB2859D0FA1", mms), StandardCharsets.UTF_8);
// System.out.println(plainText);
//
// } catch (Exception e) {
// System.out.println(e);
// }
// System.out.println(plainText);
}
}
\ No newline at end of file
package com.keymobile.sso.utils;
import com.keymobile.sso.exception.UtilException;
import org.apache.commons.lang.StringUtils;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.springframework.util.Base64Utils;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.NoSuchProviderException;
import java.security.SecureRandom;
import java.security.Security;
import java.security.spec.InvalidKeySpecException;
import java.util.Base64;
import java.util.Optional;
/**
* 功能描述:SM4-CBC 模式加解密工具类
* 密钥长度固定为16字节,IV随机生成并与密文一同存储(IV在前,密文在后)
*
* @author 张冠松
* @Classname Sm4CbcUtil
* @Date 2026/3/10 10:43
*/
public class Sm4CbcUtil {
static {
// 添加Bouncy Castle安全提供者,若失败则抛出异常(但类加载失败将导致后续所有操作不可用)
try {
Security.addProvider(new BouncyCastleProvider());
} catch (Exception e) {
throw new UtilException("Bouncy Castle 提供者注册失败", e);
}
}
private static final String ALGORITHM = "SM4";
private static final String TRANSFORMATION = "SM4/CBC/PKCS7Padding";
private static final int IV_LENGTH = 16;
private static final int SALT_LENGTH = 16;
private static final int KEY_LENGTH_BITS = 128;
private static final int PBKDF2_ITERATIONS = 100000;
/**
* 使用口令加密
*
* @param plainText 明文(UTF-8编码)
* @param password 口令
* @return Base64字符串(格式:盐(16字节) + IV(16字节) + 密文)
* @throws UtilException 加密过程中的错误
*/
public static String encrypt(String plainText, String password)
throws UtilException {
// 参数校验
if (plainText == null || password == null) {
throw new UtilException("明文和口令不能为null");
}
// 1. 生成随机盐
byte[] salt = new byte[SALT_LENGTH];
try {
new SecureRandom().nextBytes(salt);
} catch (Exception e) {
throw new UtilException("生成随机盐失败", e);
}
// 2. 从口令派生密钥
byte[] keyBytes = deriveKey(password, salt);
// 3. 生成随机IV
byte[] iv = new byte[IV_LENGTH];
try {
new SecureRandom().nextBytes(iv);
} catch (Exception e) {
throw new UtilException("生成随机IV失败", e);
}
// 4. 加密
try {
Cipher cipher = Cipher.getInstance(TRANSFORMATION, "BC");
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, ALGORITHM);
IvParameterSpec ivSpec = new IvParameterSpec(iv);
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
byte[] plainBytes = plainText.getBytes("UTF-8");
byte[] cipherBytes = cipher.doFinal(plainBytes);
// 5. 拼接盐 + IV + 密文
byte[] result = new byte[salt.length + iv.length + cipherBytes.length];
System.arraycopy(salt, 0, result, 0, salt.length);
System.arraycopy(iv, 0, result, salt.length, iv.length);
System.arraycopy(cipherBytes, 0, result, salt.length + iv.length, cipherBytes.length);
return Base64.getEncoder().encodeToString(result);
} catch (NoSuchAlgorithmException e) {
throw new UtilException("不支持的算法(SM4)", e);
} catch (NoSuchProviderException e) {
throw new UtilException("Bouncy Castle 提供者未找到", e);
} catch (NoSuchPaddingException e) {
throw new UtilException("不支持的填充模式", e);
} catch (InvalidKeyException e) {
throw new UtilException("无效的密钥(派生密钥可能不符合要求)", e);
} catch (InvalidAlgorithmParameterException e) {
throw new UtilException("无效的算法参数(IV错误)", e);
} catch (IllegalBlockSizeException e) {
throw new UtilException("加密块大小错误", e);
} catch (BadPaddingException e) {
throw new UtilException("加密填充错误", e);
} catch (Exception e) {
throw new UtilException("加密过程中发生未知错误", e);
}
}
/**
* 使用口令解密
*
* @param cipherTextBase64 Base64密文(包含盐和IV)
* @param password 口令
* @return 明文字符串
* @throws UtilException 解密过程中的错误
*/
public static String decrypt(String cipherTextBase64, String password)
throws UtilException {
// 参数校验
if (cipherTextBase64 == null || password == null) {
throw new UtilException("密文和口令不能为null");
}
// 1. Base64解码
byte[] input;
try {
input = Base64.getDecoder().decode(cipherTextBase64);
} catch (IllegalArgumentException e) {
throw new UtilException("Base64解码失败,密文格式不正确", e);
}
// 2. 检查长度是否至少包含盐和IV
if (input.length < SALT_LENGTH + IV_LENGTH) {
throw new UtilException("密文长度不足,无法提取盐和IV");
}
// 3. 提取盐、IV和密文
byte[] salt = new byte[SALT_LENGTH];
byte[] iv = new byte[IV_LENGTH];
byte[] cipherBytes = new byte[input.length - SALT_LENGTH - IV_LENGTH];
System.arraycopy(input, 0, salt, 0, SALT_LENGTH);
System.arraycopy(input, SALT_LENGTH, iv, 0, IV_LENGTH);
System.arraycopy(input, SALT_LENGTH + IV_LENGTH, cipherBytes, 0, cipherBytes.length);
// 4. 从口令派生密钥
byte[] keyBytes = deriveKey(password, salt);
// 5. 解密
try {
Cipher cipher = Cipher.getInstance(TRANSFORMATION, "BC");
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, ALGORITHM);
IvParameterSpec ivSpec = new IvParameterSpec(iv);
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
byte[] plainBytes = cipher.doFinal(cipherBytes);
return new String(plainBytes, "UTF-8");
} catch (NoSuchAlgorithmException e) {
throw new UtilException("不支持的算法(SM4)", e);
} catch (NoSuchProviderException e) {
throw new UtilException("Bouncy Castle 提供者未找到", e);
} catch (NoSuchPaddingException e) {
throw new UtilException("不支持的填充模式", e);
} catch (InvalidKeyException e) {
throw new UtilException("无效的密钥(口令错误或派生密钥无效)", e);
} catch (InvalidAlgorithmParameterException e) {
throw new UtilException("无效的算法参数(IV可能被篡改)", e);
} catch (IllegalBlockSizeException e) {
throw new UtilException("解密块大小错误(密文可能损坏)", e);
} catch (BadPaddingException e) {
throw new UtilException("解密填充错误(口令错误或密文被篡改)", e);
} catch (Exception e) {
throw new UtilException("解密过程中发生未知错误", e);
}
}
/**
* PBKDF2密钥派生(使用固定迭代次数和密钥长度)
*/
private static byte[] deriveKey(String password, byte[] salt) {
try {
javax.crypto.SecretKeyFactory factory = javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
javax.crypto.spec.PBEKeySpec spec = new javax.crypto.spec.PBEKeySpec(
password.toCharArray(), salt, PBKDF2_ITERATIONS, KEY_LENGTH_BITS);
return factory.generateSecret(spec).getEncoded();
} catch (NoSuchAlgorithmException e) {
throw new UtilException("不支持的密钥派生算法(PBKDF2WithHmacSHA256)", e);
} catch (InvalidKeySpecException e) {
throw new UtilException("无效的密钥规范(参数错误)", e);
} catch (Exception e) {
throw new UtilException("密钥派生过程中发生未知错误", e);
}
}
public static void main(String[] args) {
String[] parts = "123|12312|test".split("\\|");
System.out.println(parts.length);
System.out.println(parts[2]);
String password = "703AF86205DB25E6999CEAB2859D0FA1";
String sss = "测试:123:test";
String sign = "08ESlqUElarGW6BjxfX%2F3L%2Fe4u9CbTTNmKhBKJqy1hK5%2FhX%2BUbdi4da7rd%2BqQCPiyx8YG5xF3kyu4Eiqkss3ggN8iWMrWpBn7yRjZMaTAIY%3D";
System.out.println(sign);
String rawTicket = URLDecoder.decode(sign, StandardCharsets.UTF_8);
System.out.println(rawTicket);
try {
String encryptStr = encrypt(sss, password);
System.out.println(encryptStr);
String decryptStr = decrypt(encryptStr, password);
System.out.println(decryptStr);
String decryptStr1 = decrypt(rawTicket, password);
System.out.println(decryptStr1);
} catch (UtilException e) {
throw new RuntimeException(e);
}
}
}
package com.keymobile.sso.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 门户免登录验签请求。
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class PortalAuthReq {
/**
* SM4 加密票据(Base64 编码)
*/
private String token;
}
package com.keymobile.sso.vo;
/**
* 统一 API 响应体。
*
* @param <T> data 类型
*/
public class ResponseVO<T> {
private String code;
private String message;
private T data;
public ResponseVO() {
}
public ResponseVO(String code, String message) {
this.code = code;
this.message = message;
}
public ResponseVO(String code, String message, T data) {
this.code = code;
this.message = message;
this.data = data;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
}
......@@ -20,4 +20,17 @@ eureka:
region: default
serviceUrl:
defaultZone: http://e0:8081/eureka/
enabled: true
\ No newline at end of file
enabled: true
self:
login:
redirectUrl: http://139.198.127.54:18079/data-center/view/index
sso:
# 是否启用 IP 白名单校验(本地联调可设为 false)
ipWhitelistEnabled: true
appId: portal_asset
portalIp: 18.1.115.225
sm4Key: 703AF86205DB25E6999CEAB2859D0FA1
expireSeconds: 300
replayProtection: true
......@@ -2,6 +2,9 @@ server:
port: 8764
spring:
main:
allow-bean-definition-overriding: true
allow-circular-references: true
application:
name: auth
cloud:
......@@ -33,4 +36,5 @@ self:
login:
max-attempts: 5
lockout-duration: 30
attempt-window: 15
\ No newline at end of file
attempt-window: 15
redirectUrl: http://139.198.127.54:18079/data-center/view/index
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment