Commit 3c97de77 by Lizh

迁移修改登录逻辑处理,兼容JJWT的老版本0.9.x

parent f1db6a85
package com.jomalls.custom.app.constant;
/**
* 用户常量信息
*/
public class UserConstants {
/**
* 平台内系统用户的唯一标志
*/
public static final String SYS_USER = "SYS_USER";
/**
* 正常状态
*/
public static final String NORMAL = "0";
/**
* 异常状态
*/
public static final String EXCEPTION = "1";
/**
* 用户封禁状态
*/
public static final String USER_DISABLE = "1";
/**
* 角色正常状态
*/
public static final String ROLE_NORMAL = "0";
/**
* 角色封禁状态
*/
public static final String ROLE_DISABLE = "1";
/**
* 部门正常状态
*/
public static final String DEPT_NORMAL = "0";
/**
* 部门停用状态
*/
public static final String DEPT_DISABLE = "1";
/**
* 字典正常状态
*/
public static final String DICT_NORMAL = "0";
/**
* 是否为系统默认(是)
*/
public static final String YES = "Y";
/**
* 是否菜单外链(是)
*/
public static final String YES_FRAME = "0";
/**
* 是否菜单外链(否)
*/
public static final String NO_FRAME = "1";
/**
* 菜单类型(目录)
*/
public static final String TYPE_DIR = "M";
/**
* 菜单类型(菜单)
*/
public static final String TYPE_MENU = "C";
/**
* 菜单类型(按钮)
*/
public static final String TYPE_BUTTON = "F";
/**
* Layout组件标识
*/
public final static String LAYOUT = "Layout";
/**
* ParentView组件标识
*/
public final static String PARENT_VIEW = "ParentView";
/**
* InnerLink组件标识
*/
public final static String INNER_LINK = "InnerLink";
/**
* 校验是否唯一的返回标识
*/
public final static boolean UNIQUE = true;
public final static boolean NOT_UNIQUE = false;
/**
* 用户名长度限制
*/
public static final int USERNAME_MIN_LENGTH = 2;
public static final int USERNAME_MAX_LENGTH = 20;
/**
* 密码长度限制
*/
public static final int PASSWORD_MIN_LENGTH = 5;
public static final int PASSWORD_MAX_LENGTH = 20;
}
......@@ -6,7 +6,7 @@ import com.jomalls.custom.app.vo.system.RouterVO;
import java.util.List;
/**
* 登录服务接口
* 登录服务接口(与 custom-back SysLoginService 对齐)
*
* @author Lizh
*/
......@@ -17,9 +17,11 @@ public interface SysLoginService {
*
* @param username 用户名
* @param password 密码
* @param code 验证码
* @param uuid 验证码唯一标识
* @return JWT token
*/
String login(String username, String password);
String login(String username, String password, String code, String uuid);
/**
* 获取当前登录用户信息
......
......@@ -38,7 +38,7 @@ public class SysPasswordService {
}
/**
* 验证密码(与 custom-back SysPasswordService.validate 对齐)
* 验证密码
* <p>
* 登录失败时累计重试次数并锁定账户
*/
......@@ -57,7 +57,9 @@ public class SysPasswordService {
* 登录失败后增加重试计数,超过阈值锁定账户
*/
public void recordLoginFailure(String username) {
if (stringRedisTemplate == null) return;
if (stringRedisTemplate == null) {
return;
}
String key = getCacheKey(username);
Long retryCount = stringRedisTemplate.opsForValue().increment(key);
if (retryCount != null && retryCount == 1) {
......
package com.jomalls.custom.app.utils;
import org.apache.commons.lang3.StringUtils;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
/**
* 字符集工具类
*/
public class CharsetKit {
/**
* ISO-8859-1
*/
public static final String ISO_8859_1 = "ISO-8859-1";
/**
* UTF-8
*/
public static final String UTF_8 = "UTF-8";
/**
* GBK
*/
public static final String GBK = "GBK";
/**
* ISO-8859-1
*/
public static final Charset CHARSET_ISO_8859_1 = Charset.forName(ISO_8859_1);
/**
* UTF-8
*/
public static final Charset CHARSET_UTF_8 = Charset.forName(UTF_8);
/**
* GBK
*/
public static final Charset CHARSET_GBK = Charset.forName(GBK);
/**
* 转换为Charset对象
*
* @param charset 字符集,为空则返回默认字符集
* @return Charset
*/
public static Charset charset(String charset) {
return StringUtils.isEmpty(charset) ? Charset.defaultCharset() : Charset.forName(charset);
}
/**
* 转换字符串的字符集编码
*
* @param source 字符串
* @param srcCharset 源字符集,默认ISO-8859-1
* @param destCharset 目标字符集,默认UTF-8
* @return 转换后的字符集
*/
public static String convert(String source, String srcCharset, String destCharset) {
return convert(source, Charset.forName(srcCharset), Charset.forName(destCharset));
}
/**
* 转换字符串的字符集编码
*
* @param source 字符串
* @param srcCharset 源字符集,默认ISO-8859-1
* @param destCharset 目标字符集,默认UTF-8
* @return 转换后的字符集
*/
public static String convert(String source, Charset srcCharset, Charset destCharset) {
if (null == srcCharset) {
srcCharset = StandardCharsets.ISO_8859_1;
}
if (null == destCharset) {
destCharset = StandardCharsets.UTF_8;
}
if (StringUtils.isEmpty(source) || srcCharset.equals(destCharset)) {
return source;
}
return new String(source.getBytes(srcCharset), destCharset);
}
/**
* @return 系统字符集编码
*/
public static String systemCharset() {
return Charset.defaultCharset().name();
}
}
package com.jomalls.custom.app.utils;
import org.apache.commons.lang3.time.DateFormatUtils;
import java.lang.management.ManagementFactory;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.time.*;
import java.util.Date;
/**
* 时间工具类
*/
public class DateUtils extends org.apache.commons.lang3.time.DateUtils {
public static String YYYY = "yyyy";
public static String YYYY_MM = "yyyy-MM";
public static String YYYY_MM_DD = "yyyy-MM-dd";
public static String YYYYMMDDHHMMSS = "yyyyMMddHHmmss";
public static String YYYY_MM_DD_HH_MM_SS = "yyyy-MM-dd HH:mm:ss";
private static String[] parsePatterns = {
"yyyy-MM-dd", "yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "yyyy-MM",
"yyyy/MM/dd", "yyyy/MM/dd HH:mm:ss", "yyyy/MM/dd HH:mm", "yyyy/MM",
"yyyy.MM.dd", "yyyy.MM.dd HH:mm:ss", "yyyy.MM.dd HH:mm", "yyyy.MM"};
/**
* 获取当前Date型日期
*
* @return Date() 当前日期
*/
public static Date getNowDate() {
return new Date();
}
/**
* 获取当前日期, 默认格式为yyyy-MM-dd
*
* @return String
*/
public static String getDate() {
return dateTimeNow(YYYY_MM_DD);
}
public static final String getTime() {
return dateTimeNow(YYYY_MM_DD_HH_MM_SS);
}
public static final String dateTimeNow() {
return dateTimeNow(YYYYMMDDHHMMSS);
}
public static final String dateTimeNow(final String format) {
return parseDateToStr(format, new Date());
}
public static final String dateTime(final Date date) {
return parseDateToStr(YYYY_MM_DD, date);
}
public static final String parseDateToStr(final String format, final Date date) {
return new SimpleDateFormat(format).format(date);
}
public static final Date dateTime(final String format, final String ts) {
try {
return new SimpleDateFormat(format).parse(ts);
} catch (ParseException e) {
throw new RuntimeException(e);
}
}
/**
* 日期路径 即年/月/日 如2018/08/08
*/
public static final String datePath() {
Date now = new Date();
return DateFormatUtils.format(now, "yyyy/MM/dd");
}
/**
* 日期路径 即年/月/日 如20180808
*/
public static final String dateTime() {
Date now = new Date();
return DateFormatUtils.format(now, "yyyyMMdd");
}
/**
* 日期型字符串转化为日期 格式
*/
public static Date parseDate(Object str) {
if (str == null) {
return null;
}
try {
return parseDate(str.toString(), parsePatterns);
} catch (ParseException e) {
return null;
}
}
/**
* 获取服务器启动时间
*/
public static Date getServerStartDate() {
long time = ManagementFactory.getRuntimeMXBean().getStartTime();
return new Date(time);
}
/**
* 计算相差天数
*/
public static int differentDaysByMillisecond(Date date1, Date date2) {
return Math.abs((int) ((date2.getTime() - date1.getTime()) / (1000 * 3600 * 24)));
}
/**
* 计算时间差
*
* @param endDate 最后时间
* @param startTime 开始时间
* @return 时间差(天/小时/分钟)
*/
public static String timeDistance(Date endDate, Date startTime) {
long nd = 1000 * 24 * 60 * 60;
long nh = 1000 * 60 * 60;
long nm = 1000 * 60;
// long ns = 1000;
// 获得两个时间的毫秒时间差异
long diff = endDate.getTime() - startTime.getTime();
// 计算差多少天
long day = diff / nd;
// 计算差多少小时
long hour = diff % nd / nh;
// 计算差多少分钟
long min = diff % nd % nh / nm;
// 计算差多少秒//输出结果
// long sec = diff % nd % nh % nm / ns;
return day + "天" + hour + "小时" + min + "分钟";
}
/**
* 增加 LocalDateTime ==> Date
*/
public static Date toDate(LocalDateTime temporalAccessor) {
ZonedDateTime zdt = temporalAccessor.atZone(ZoneId.systemDefault());
return Date.from(zdt.toInstant());
}
/**
* 增加 LocalDate ==> Date
*/
public static Date toDate(LocalDate temporalAccessor) {
LocalDateTime localDateTime = LocalDateTime.of(temporalAccessor, LocalTime.of(0, 0, 0));
ZonedDateTime zdt = localDateTime.atZone(ZoneId.systemDefault());
return Date.from(zdt.toInstant());
}
}
......@@ -2,17 +2,49 @@ package com.jomalls.custom.app.vo.system;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 路由元数据 VO
* 路由元数据 VO(与 custom-back MetaVo 对齐)
*
* @author Lizh
*/
@Data
@NoArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class MetaVO {
/** 路由在侧边栏和面包屑中展示的名字 */
private String title;
/** 路由图标 */
private String icon;
private Boolean isCache;
private Boolean isFrame;
/** 是否不缓存(true = 不被 keep-alive 缓存) */
private Boolean noCache;
/** 内链地址(http(s):// 开头) */
private String link;
public MetaVO(String title, String icon) {
this.title = title;
this.icon = icon;
}
public MetaVO(String title, String icon, boolean noCache) {
this.title = title;
this.icon = icon;
this.noCache = noCache;
}
public MetaVO(String title, String icon, String link) {
this.title = title;
this.icon = icon;
this.link = link;
}
public MetaVO(String title, String icon, boolean noCache, String link) {
this.title = title;
this.icon = icon;
this.noCache = noCache;
if (link != null && (link.startsWith("http://") || link.startsWith("https://"))) {
this.link = link;
}
}
}
......@@ -18,6 +18,8 @@ public class RouterVO {
private Boolean hidden;
private String redirect;
private String component;
/** 路由参数(如 {"id": 1, "name": "ry"}) */
private String query;
private Boolean alwaysShow;
private MetaVO meta;
private List<RouterVO> children;
......
......@@ -13,6 +13,7 @@ import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
......@@ -48,13 +49,13 @@ public class TokenCompatibilityParser {
* @throws JwtException 解析失败
*/
public Claims parseTokenCompatibly(String token, String secret) throws JwtException {
// 方式1:兼容旧版本 JJWT 0.9.1 短密钥 token
Claims result = tryLegacyParse(token, secret);
// 方式1:JJWT 0.12.x 标准方式 + SHA-256 派生密钥(custom-server 新 token)
Claims result = tryStandardParse(token, secret);
if (result != null) {
return result;
}
// 方式2:优先尝试 JJWT 0.12.x 标准方式(适用于新版长密钥生成的 token
result = tryStandardParse(token, secret);
// 方式2:手动 HMAC-SHA512 验证(兼容 JJWT 0.9.1 旧 token,先试 SHA-256 再试原始密钥
result = tryLegacyParse(token, secret);
if (result != null) {
return result;
}
......@@ -63,17 +64,19 @@ public class TokenCompatibilityParser {
}
/**
* JJWT 0.12.x 标准方式:secret 直接作为原始密钥字节
* JJWT 0.12.x 标准方式:对 secret 做 SHA-256 派生密钥(与 TokenHandle.createToken 一致)
*/
private Claims tryStandardParse(String token, String secret) {
try {
SecretKey key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256");
byte[] derivedKey = digest.digest(secret.getBytes(StandardCharsets.UTF_8));
SecretKey key = Keys.hmacShaKeyFor(derivedKey);
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
} catch (JwtException e) {
} catch (JwtException | NoSuchAlgorithmException e) {
log.debug("标准方式解析失败: {}", e.getMessage());
}
return null;
......@@ -81,24 +84,28 @@ public class TokenCompatibilityParser {
/**
* 兼容 JJWT 0.9.1 旧格式 token
*
* <p>custom-back 密钥推导: signingKey = Base64(secret.getBytes(UTF_8))
* <p>因为密钥只有 8 字节,JJWT 0.12.x 拒绝,所以手工验证签名后解码 payload
* <p>
* 依次尝试两种密钥验证签名:
* <ol>
* <li>SHA-256 派生密钥(32字节)— custom-server 新 token</li>
* <li>原始密钥字节 — custom-back 旧 token</li>
* </ol>
*/
private Claims tryLegacyParse(String token, String secret) {
try {
// 1. 按 JJWT 0.9.1 方式推导签名密钥
byte[] signingKey = deriveLegacySigningKey(secret);
// 2. 手动验证 HMAC-SHA512 签名
if (!verifyHmacSha512Signature(token, signingKey)) {
log.debug("旧版 token 签名验证失败");
return null;
// 先尝试 SHA-256 派生密钥(与 createToken 一致)
byte[] sha256Key = deriveSha256Key(secret);
if (verifyHmacSha512Signature(token, sha256Key)) {
return decodePayloadToClaims(token);
}
// 3. 签名有效,手动解码 payload 为 Claims
return decodePayloadToClaims(token);
// 回退:尝试原始密钥(兼容 custom-back 旧 token)
byte[] legacyKey = deriveLegacySigningKey(secret);
if (!java.util.Arrays.equals(sha256Key, legacyKey)
&& verifyHmacSha512Signature(token, legacyKey)) {
return decodePayloadToClaims(token);
}
log.debug("旧版 token 签名验证失败");
return null;
} catch (Exception e) {
log.debug("旧版兼容解析失败: {}", e.getMessage());
return null;
......@@ -106,6 +113,18 @@ public class TokenCompatibilityParser {
}
/**
* SHA-256 派生 32 字节密钥(与 TokenHandle.createToken 一致)
*/
private static byte[] deriveSha256Key(String secret) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return digest.digest(secret.getBytes(StandardCharsets.UTF_8));
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("SHA-256 算法不可用", e);
}
}
/**
* 按 JJWT 0.9.1 方式推导签名密钥
*
* <p>custom-back 调用链:
......@@ -128,7 +147,7 @@ public class TokenCompatibilityParser {
}
/**
* 手动验证 HMAC-SHA512 签名
* 手动验证 HMAC 签名(根据密钥长度自动选择 HS256/HS384/HS512)
*
* @param token JWT token (header.payload.signature)
* @param keyBytes 签名密钥字节
......@@ -141,18 +160,32 @@ public class TokenCompatibilityParser {
return false;
}
// 读取 JWT header 确定算法
String headerJson = new String(Base64.getUrlDecoder().decode(parts[0]), StandardCharsets.UTF_8);
@SuppressWarnings("unchecked")
Map<String, Object> headerMap = OBJECT_MAPPER.readValue(headerJson, Map.class);
String alg = (String) headerMap.getOrDefault("alg", "HS512");
String headerPayload = parts[0] + "." + parts[1];
byte[] signatureBytes = Base64.getUrlDecoder().decode(parts[2]);
Mac mac = Mac.getInstance("HmacSHA512");
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "HmacSHA512");
// 根据算法选择对应的 Mac 实例
String macAlgorithm;
switch (alg) {
case "HS256": macAlgorithm = "HmacSHA256"; break;
case "HS384": macAlgorithm = "HmacSHA384"; break;
default: macAlgorithm = "HmacSHA512"; break;
}
Mac mac = Mac.getInstance(macAlgorithm);
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, macAlgorithm);
mac.init(keySpec);
byte[] computedSignature = mac.doFinal(
headerPayload.getBytes(StandardCharsets.UTF_8));
return MessageDigest.isEqual(computedSignature, signatureBytes);
} catch (Exception e) {
log.debug("HMAC-SHA512 签名验证异常: {}", e.getMessage());
log.debug("HMAC 签名验证异常: {}", e.getMessage());
return false;
}
}
......
......@@ -70,7 +70,6 @@ public class TokenHandle {
* <li>Redis 不可用或 key 不存在 → 回退到 JWT claims 直接提取</li>
* </ol>
*
* @param request 请求对象
* @return 用户信息,解析失败返回 null
*/
public LoginUser getLoginUser(String token) {
......@@ -145,16 +144,19 @@ public class TokenHandle {
loginUser.setExpireTime(((Number) expireObj).longValue());
}
// 权限列表
@SuppressWarnings("unchecked")
HashSet<String> permissionsList = (HashSet<String>) data.get("permissions");
if (permissionsList != null) {
loginUser.setPermissions(new HashSet<>(permissionsList));
} else {
loginUser.setPermissions(new HashSet<>());
// 权限列表(fastjson2 反序列化为 JSONArray,需兼容 Collection 类型)
Set<String> permissionsSet = new HashSet<>();
Object permsObj = data.get("permissions");
if (permsObj instanceof java.util.Collection) {
for (Object p : (java.util.Collection<?>) permsObj) {
if (p != null) {
permissionsSet.add(String.valueOf(p));
}
}
}
loginUser.setPermissions(permissionsSet);
log.debug("从 Redis 获取用户[{}]信息成功,权限数: {}", username, permissionsList != null ? permissionsList.size() : 0);
log.debug("从 Redis 获取用户[{}]信息成功,权限数: {}", username, permissionsSet.size());
return loginUser;
} catch (Exception e) {
......@@ -168,13 +170,17 @@ public class TokenHandle {
*/
private LoginUser buildFromClaims(Claims claims) {
Long userId = claims.get("id", Long.class);
// 兼容 custom-back (sub) 和旧 custom-server (account) 两种 username 位置
String username = claims.get("account", String.class);
if (username == null) {
username = claims.get("sub", String.class); // custom-back JWT_USERNAME
}
if (username == null) {
username = claims.get("username", String.class);
}
if (userId == null || !StringUtils.hasText(username)) {
log.warn("JWT claims 缺少 id 或 account/username");
log.warn("JWT claims 缺少 id 或 account/sub/username");
return null;
}
......@@ -228,46 +234,74 @@ public class TokenHandle {
}
/**
* 创建令牌(JJWT 0.12.x 标准格式
*
* @param loginUser 登录用户信息
* @return JWT token 字符串
* 创建令牌(与 custom-back TokenService.createToken 生成格式完全一致
* <p>
* JWT:HS512 签名 + raw secret 密钥,JWT 仅含 login_user_key UUID + 基本标识。
* 用户权限、部门等全量信息存储在 Redis login_tokens:{uuid} 中。
*/
public String createToken(LoginUser loginUser) {
String token = java.util.UUID.randomUUID().toString();
loginUser.setToken(token);
String uuid = java.util.UUID.randomUUID().toString().replace("-", "");
loginUser.setToken(uuid);
loginUser.setLoginTime(System.currentTimeMillis());
loginUser.setExpireTime(System.currentTimeMillis() + expireTime * 60 * 1000L);
// 存入 Redis(custom-back 兼容路径
// 存入 Redis(全量用户信息 + 权限,与 custom-back 一致
if (stringRedisTemplate != null) {
String redisKey = REDIS_TOKEN_PREFIX + token;
String redisKey = REDIS_TOKEN_PREFIX + uuid;
stringRedisTemplate.opsForValue().set(
redisKey,
JSON.toJSONString(loginUser),
expireTime * 60,
expireTime * 60L,
java.util.concurrent.TimeUnit.SECONDS
);
}
// 生成 JWT
// 对 secret 做 SHA-256 哈希以派生足够长度的密钥(JJWT 0.12.x 要求 ≥256 位)
// 手动构建 JWT(与 custom-back JJWT 0.9.1 HS512 + raw secret 字节级一致)
return buildJwtCompatibly(uuid, loginUser);
}
/**
* 手动构建 JWT(与 custom-back 格式完全一致)
* <p>
* Header: {"alg":"HS512"}
* Payload: {"login_user_key":uuid, "sub":username, "account":username, "id":userId, "exp":24h}
* Sign: HMAC-SHA512(header.payload, secret.getBytes(UTF_8))
*/
private String buildJwtCompatibly(String uuid, LoginUser loginUser) {
try {
java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256");
byte[] keyBytes = digest.digest(secret.getBytes(java.nio.charset.StandardCharsets.UTF_8));
SecretKey signingKey = Keys.hmacShaKeyFor(keyBytes);
return Jwts.builder()
.claim("login_user_key", token)
.claim("id", loginUser.getUserId())
.claim("account", loginUser.getUsername())
.claim("deptId", loginUser.getDeptId())
.claim("permissions", loginUser.getPermissions())
.expiration(new java.util.Date(loginUser.getExpireTime()))
.signWith(signingKey)
.compact();
} catch (java.security.NoSuchAlgorithmException e) {
throw new RuntimeException("SHA-256 算法不可用,无法生成 JWT", e);
java.util.Base64.Encoder encoder = java.util.Base64.getUrlEncoder().withoutPadding();
// Header: {"alg":"HS512"}
String header = "{\"alg\":\"HS512\"}";
String headerBase64 = encoder.encodeToString(
header.getBytes(java.nio.charset.StandardCharsets.UTF_8));
// Payload: 仅含 UUID + 基本标识,不含 permissions/deptId(全量信息在 Redis)
java.util.LinkedHashMap<String, Object> claims = new java.util.LinkedHashMap<>();
claims.put("login_user_key", uuid);
claims.put("sub", loginUser.getUsername());
claims.put("account", loginUser.getUsername());
claims.put("id", loginUser.getUserId());
// JWT 24 小时硬过期(与 custom-back TOKEN_TIMEOUT = 86400000L 一致)
claims.put("exp", (System.currentTimeMillis() + 86400000L) / 1000);
String payload = JSON.toJSONString(claims);
String payloadBase64 = encoder.encodeToString(
payload.getBytes(java.nio.charset.StandardCharsets.UTF_8));
// HS512 签名(raw secret 字节,与 custom-back 一致)
String headerPayload = headerBase64 + "." + payloadBase64;
javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA512");
javax.crypto.spec.SecretKeySpec keySpec = new javax.crypto.spec.SecretKeySpec(
secret.getBytes(java.nio.charset.StandardCharsets.UTF_8), "HmacSHA512");
mac.init(keySpec);
byte[] signature = mac.doFinal(
headerPayload.getBytes(java.nio.charset.StandardCharsets.UTF_8));
String signatureBase64 = encoder.encodeToString(signature);
return headerBase64 + "." + payloadBase64 + "." + signatureBase64;
} catch (Exception e) {
throw new RuntimeException("构建 JWT 失败", e);
}
}
......@@ -301,13 +335,39 @@ public class TokenHandle {
return tokenCompatibilityParser.parseTokenCompatibly(token, secret);
}
/** Token 自动刷新阈值:剩余不足 20 分钟时自动续期 */
private static final long MILLIS_MINUTE_TWENTY = 20 * 60 * 1000L;
/**
* 验证令牌有效期
* 刷新 Redis 中缓存的用户信息(权限变更后调用)
*/
public void refreshToken(LoginUser loginUser) {
if (stringRedisTemplate != null && loginUser != null
&& org.springframework.util.StringUtils.hasText(loginUser.getToken())) {
String redisKey = REDIS_TOKEN_PREFIX + loginUser.getToken();
loginUser.setLoginTime(System.currentTimeMillis());
loginUser.setExpireTime(System.currentTimeMillis() + expireTime * 60 * 1000L);
stringRedisTemplate.opsForValue().set(
redisKey,
JSON.toJSONString(loginUser),
expireTime * 60,
java.util.concurrent.TimeUnit.SECONDS
);
}
}
/**
* 验证令牌有效期(剩余不足20分钟时自动续期,与 custom-back TokenService.verifyToken 对齐)
*/
public boolean verifyToken(LoginUser loginUser) {
if (loginUser == null || loginUser.getExpireTime() == null) {
return false;
}
return loginUser.getExpireTime() > System.currentTimeMillis();
long expireTime = loginUser.getExpireTime();
long currentTime = System.currentTimeMillis();
if (expireTime - currentTime <= MILLIS_MINUTE_TWENTY) {
refreshToken(loginUser);
}
return expireTime > currentTime;
}
}
package com.jomalls.custom.utils;
import com.fasterxml.jackson.annotation.JsonAnyGetter;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.jomalls.custom.enums.CodeEnum;
import lombok.Getter;
import lombok.Setter;
import java.io.Serial;
import java.io.Serializable;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* @Author: Lizh
* @Date: 2026/5/28 10:29
* @Description: 统一返回结果类
* @Version: 1.0
* 统一返回结果类
* <p>
* 支持两种返回风格:
* <ul>
* <li><b>v3 包裹风格</b>:{@code R.ok(data)} → {@code {"code":10000, "msg":"OK", "data":{...}}}</li>
* <li><b>v2 平铺风格</b>:{@code R.ok().put("token", xxx).put("user", {...})} →
* {@code {"code":200, "msg":"success", "token":"xxx", "user":{...}}}(仿 custom-back AjaxResult)</li>
* </ul>
*
* @param <T> data 类型
* @author Lizh
* @date 2026/5/28 10:29
*/
@Setter
@Getter
@JsonInclude(JsonInclude.Include.NON_NULL)
public class R<T> implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
......@@ -23,6 +37,12 @@ public class R<T> implements Serializable {
private String msg;
private T data;
/**
* 平铺字段(仿 AjaxResult HashMap 风格),序列化时展开到 JSON 根级别
*/
@JsonAnySetter
private Map<String, Object> extra;
public R() {
}
......@@ -56,4 +76,33 @@ public class R<T> implements Serializable {
return new R<>(CodeEnum.FAIL.getCode(), msg, null);
}
}
\ No newline at end of file
/**
* 添加平铺字段(展开到 JSON 根级别,不包在 data 内),兼容custom_back中的某些接口,避免前端修改
*
* @param key 字段名
* @param value 字段值
* @return this(链式调用)
*/
public R<T> put(String key, Object value) {
if (this.extra == null) {
this.extra = new LinkedHashMap<>();
}
this.extra.put(key, value);
return this;
}
/**
* 获取所有平铺字段(供 Jackson 序列化展开到根级别)
*/
@JsonAnyGetter
public Map<String, Object> getExtra() {
return extra;
}
/**
* 获取某个平铺字段
*/
public Object get(String key) {
return extra != null ? extra.get(key) : null;
}
}
......@@ -73,7 +73,17 @@ public class RestResponseBodyConfig implements ResponseBodyAdvice<Object> {
return body;
}
}
return body instanceof R ? body : R.ok(body);
R<?> r = body instanceof R ? (R<?>) body : R.ok(body);
// 未避免前端修改,适配原来的返回结果,/api/v2 返回 code=200(兼容 custom-back AjaxResult),/api/v3 返回 code=10000
if (requestPath.startsWith("/api/v2")) {
if (r.getCode() == CodeEnum.OK.getCode()) {
r.setCode(CodeEnum.SUCCESS.getCode());
r.setMsg(CodeEnum.SUCCESS.getMsg());
}
}
return r;
}
}
}
......
package com.jomalls.custom.webapp.controller.system;
import com.jomalls.custom.utils.R;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.Random;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* 验证码控制器(与 custom-back CaptchaController 对齐)
* <p>
* 生成数学验证码图片,返回 base64 图片和 UUID 标识
*
* @author Lizh
*/
@Slf4j
@RestController
@RequiredArgsConstructor
@Tag(name = "验证码")
public class CaptchaController {
private static final String CAPTCHA_CODE_KEY = "captcha_codes:";
private static final int CAPTCHA_EXPIRE_MINUTES = 2;
private static final int IMAGE_WIDTH = 130;
private static final int IMAGE_HEIGHT = 48;
private final StringRedisTemplate stringRedisTemplate;
/**
* 生成验证码
*/
@GetMapping("/captchaImage")
@Operation(summary = "生成验证码")
@SuppressWarnings("rawtypes")
public R getCode() {
// 生成 UUID 标识
String uuid = UUID.randomUUID().toString().replace("-", "");
// 生成数学验证码
int a = new Random().nextInt(50) + 1;
int b = new Random().nextInt(50) + 1;
int result = a + b;
String expression = a + " + " + b + " = ?";
String captchaText = String.valueOf(result);
// 存入 Redis,2 分钟有效
try {
if (stringRedisTemplate != null) {
stringRedisTemplate.opsForValue().set(
CAPTCHA_CODE_KEY + uuid,
captchaText,
CAPTCHA_EXPIRE_MINUTES,
TimeUnit.MINUTES
);
}
} catch (Exception e) {
log.warn("验证码存入 Redis 失败: {}", e.getMessage());
}
// 生成验证码图片
String imgBase64 = generateCaptchaImage(expression);
return R.ok()
.put("uuid", uuid)
.put("img", imgBase64)
.put("captchaEnabled", true);
}
/**
* 生成数学验证码图片,返回 base64 字符串
*/
private String generateCaptchaImage(String text) {
try {
BufferedImage image = new BufferedImage(IMAGE_WIDTH, IMAGE_HEIGHT, BufferedImage.TYPE_INT_RGB);
Graphics2D g = image.createGraphics();
// 背景
g.setColor(Color.WHITE);
g.fillRect(0, 0, IMAGE_WIDTH, IMAGE_HEIGHT);
// 干扰线
Random random = new Random();
g.setColor(new Color(200, 200, 200));
for (int i = 0; i < 5; i++) {
int x1 = random.nextInt(IMAGE_WIDTH);
int y1 = random.nextInt(IMAGE_HEIGHT);
int x2 = random.nextInt(IMAGE_WIDTH);
int y2 = random.nextInt(IMAGE_HEIGHT);
g.drawLine(x1, y1, x2, y2);
}
// 文字
g.setColor(new Color(50, 50, 50));
g.setFont(new Font("Arial", Font.BOLD, 22));
g.drawString(text, 10, 30);
g.dispose();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(image, "png", baos);
return "data:image/png;base64," + Base64.getEncoder().encodeToString(baos.toByteArray());
} catch (Exception e) {
log.warn("生成验证码图片失败: {}", e.getMessage());
return "";
}
}
}
......@@ -11,12 +11,10 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 登录鉴权控制器
* 登录鉴权控制器(返回风格与 custom-back AjaxResult 对齐)
*
* @author Lizh
*/
......@@ -29,29 +27,36 @@ public class SysLoginController {
private final SysLoginService sysLoginService;
/**
* 用户登录
* 用户登录 — 平铺返回 token(仿 AjaxResult.put("token", xxx))
*/
@PostMapping("/api/v2/login")
@Operation(summary = "用户登录")
public R<Map<String, String>> login(@RequestBody LoginDTO loginDTO) {
@SuppressWarnings("rawtypes")
public R login(@RequestBody LoginDTO loginDTO) {
String username = loginDTO.getUsername();
String password = loginDTO.getPassword();
String code = loginDTO.getCode();
String uuid = loginDTO.getUuid();
log.info("用户[{}]尝试登录", username);
String token = sysLoginService.login(username, password);
Map<String, String> result = new HashMap<>();
result.put("token", token);
String token = sysLoginService.login(username, password, code, uuid);
log.info("用户[{}]登录成功", username);
return R.ok(result);
return R.ok().put("token", token);
}
/**
* 获取用户信息
* 获取用户信息 — 平铺返回 user/roles/permissions(仿 AjaxResult.put 风格)
*/
@GetMapping("/api/v2/getInfo")
@Operation(summary = "获取当前用户信息")
public R<LoginUserInfoVO> getInfo() {
@SuppressWarnings("rawtypes")
public R getInfo() {
LoginUserInfoVO info = sysLoginService.getInfo();
return R.ok(info);
return R.ok()
.put("user", info.getUser())
.put("roles", info.getRoles())
.put("permissions", info.getPermissions())
.put("isDefaultModifyPwd", info.getIsDefaultModifyPwd())
.put("isPasswordExpired", info.getIsPasswordExpired());
}
/**
......@@ -69,7 +74,8 @@ public class SysLoginController {
*/
@PostMapping("/api/v2/logout")
@Operation(summary = "退出登录")
public R<Void> logout() {
@SuppressWarnings("rawtypes")
public R logout() {
sysLoginService.logout();
return R.ok();
}
......
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