diff --git a/demo/backend/src/main/java/com/example/demo/Controller/UserController.java b/demo/backend/src/main/java/com/example/demo/Controller/UserController.java new file mode 100644 index 0000000..56b0fd0 --- /dev/null +++ b/demo/backend/src/main/java/com/example/demo/Controller/UserController.java @@ -0,0 +1,37 @@ +package org.lingniu.sdk.web; + +import com.alibaba.fastjson2.JSON; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.lingniu.sdk.model.base.CommonResult; +import org.lingniu.sdk.model.user.UserInfo; +import org.lingniu.sdk.utils.HttpClientUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RequestMapping("/test") +@RestController +public class UserController { + + private final OAuth2ClientProperties oAuth2ClientProperties; + private final ObjectMapper objectMapper; + + public UserController(OAuth2ClientProperties oAuth2ClientProperties, ObjectMapper objectMapper) { + this.oAuth2ClientProperties = oAuth2ClientProperties; + this.objectMapper = objectMapper; + } + @GetMapping("/getUserInfo") + @PreAuthorize("@ss.hasPermi('user:info')") + public CommonResult getUserInfo(@AuthenticationPrincipal UserInfo userInfo) throws Exception { + return CommonResult.success(userInfo); + } + +} diff --git a/sdk/backend/oauth2-login-sdk/README.md b/sdk/backend/oauth2-login-sdk/README.md new file mode 100644 index 0000000..238e15a --- /dev/null +++ b/sdk/backend/oauth2-login-sdk/README.md @@ -0,0 +1,105 @@ +# 一、使用说明 +1.引入依赖 +```xml + + org.lingniu + oauth2-login-sdk + 1.0-SNAPSHOT + +``` +2.添加配置 +```yaml +spring: + security: + oauth2: + resourceserver: + jwt: + # 资源服务器 认证公钥地址 + jwk-set-uri: http://localhost:8000/oauth2/jwks + client: + registration: + portal: + # 统一登录颁发的client_id + client-id: xxx + # 统一登录颁发的秘钥 + client-secret: xxx + # 当前对接客户端名称 随便填 + client-name: xxx + # 认证类型 使用授权码类型 + authorization-grant-type: authorization_code + # 认证地址 + redirect-uri: http://106.14.217.120/portal-ui/callback + # 权限范围 + scope: + - openid + - profile + # 返回权限 + - perms + provider: idp + + provider: + idp: + # sso登录地址 + authorization-uri: http://106.14.217.120/idp-ui/sso + # token 获取接口 + token-uri: http://localhost:8082/oauth2/token + # 用户信息接口 + user-info-uri: http://localhost:8082/userinfo + # 认证公钥地址 + jwk-set-uri: http://localhost:8082/oauth2/jwks + # 用户信息属性 + user-name-attribute: sub +``` +3. 启动项目 +# 二 、 权限配置 +如果不做额外配置,接入成功后默认所有接口都是登录成功后即可访问,如果需要对接口进行更精确精细化的权限控制,提供了如下注解 + +- @PreAuthorize:方法执行前进行权限检查 +- @PostAuthorize:方法执行后进行权限检查 +- @Secured:类似于 @PreAuthorize +- security提供了许多默认表达式 + +![img.png](img.png) + +结合SpEl表达是进行复杂配置 +```java +@Service +public class HelloService { + @PreAuthorize("principal.username.equals('admin')") + public String hello() { + return "hello"; + } + + @PreAuthorize("principal.username.equals(#abc)") + public String admin(String abc) { + return "admin"; + } + + @Secured({"ROLE_user"}) + public String user() { + return "user"; + } + + @PreAuthorize("#age>98") + public String getAge(Integer age) { + return String.valueOf(age); + } + @PostAuthorize("returnObject == null || returnObject.id%2==0") + public User findUserById(Long id) { + // 根据id查找用户,无论用户是否存在,id是偶数的用户才能获取到结果 + // 实现根据id查找用户的逻辑... + return userRepository.findById(id).orElse(null); + } + @GetMapping("/testPermission1") + @PreAuthorize("@ss.hasPermission('def')") + public String testPermission1() { + return "testPermission1 有权访问"; + } + @GetMapping("/testPermission2") + @PreAuthorize("@ss.hasPermission(#code)") + public String testPermission2(String code) { + return "testPermission2 有权访问"; + } +} +``` + diff --git a/sdk/backend/oauth2-login-sdk/img.png b/sdk/backend/oauth2-login-sdk/img.png new file mode 100644 index 0000000..16b5999 Binary files /dev/null and b/sdk/backend/oauth2-login-sdk/img.png differ diff --git a/sdk/backend/oauth2-login-sdk/src/main/java/org/lingniu/sdk/common/convert/Convert.java b/sdk/backend/oauth2-login-sdk/src/main/java/org/lingniu/sdk/common/convert/Convert.java new file mode 100644 index 0000000..ceb92d0 --- /dev/null +++ b/sdk/backend/oauth2-login-sdk/src/main/java/org/lingniu/sdk/common/convert/Convert.java @@ -0,0 +1,1021 @@ +package org.lingniu.sdk.common.convert; + + +import org.springframework.util.StringUtils; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.math.RoundingMode; +import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.text.NumberFormat; +import java.util.Set; + +/** + * 类型转换器 + * + * @author sdk + */ +public class Convert +{ + /** + * 转换为字符串
+ * 如果给定的值为null,或者转换失败,返回默认值
+ * 转换失败不会报错 + * + * @param value 被转换的值 + * @param defaultValue 转换错误时的默认值 + * @return 结果 + */ + public static String toStr(Object value, String defaultValue) + { + if (null == value) + { + return defaultValue; + } + if (value instanceof String) + { + return (String) value; + } + return value.toString(); + } + + /** + * 转换为字符串
+ * 如果给定的值为null,或者转换失败,返回默认值null
+ * 转换失败不会报错 + * + * @param value 被转换的值 + * @return 结果 + */ + public static String toStr(Object value) + { + return toStr(value, null); + } + + /** + * 转换为字符
+ * 如果给定的值为null,或者转换失败,返回默认值
+ * 转换失败不会报错 + * + * @param value 被转换的值 + * @param defaultValue 转换错误时的默认值 + * @return 结果 + */ + public static Character toChar(Object value, Character defaultValue) + { + if (null == value) + { + return defaultValue; + } + if (value instanceof Character) + { + return (Character) value; + } + + final String valueStr = toStr(value, null); + return StringUtils.hasLength(valueStr) ? defaultValue : valueStr.charAt(0); + } + + /** + * 转换为字符
+ * 如果给定的值为null,或者转换失败,返回默认值null
+ * 转换失败不会报错 + * + * @param value 被转换的值 + * @return 结果 + */ + public static Character toChar(Object value) + { + return toChar(value, null); + } + + /** + * 转换为byte
+ * 如果给定的值为null,或者转换失败,返回默认值
+ * 转换失败不会报错 + * + * @param value 被转换的值 + * @param defaultValue 转换错误时的默认值 + * @return 结果 + */ + public static Byte toByte(Object value, Byte defaultValue) + { + if (value == null) + { + return defaultValue; + } + if (value instanceof Byte) + { + return (Byte) value; + } + if (value instanceof Number) + { + return ((Number) value).byteValue(); + } + final String valueStr = toStr(value, null); + if (StringUtils.isEmpty(valueStr)) + { + return defaultValue; + } + try + { + return Byte.parseByte(valueStr); + } + catch (Exception e) + { + return defaultValue; + } + } + + /** + * 转换为byte
+ * 如果给定的值为null,或者转换失败,返回默认值null
+ * 转换失败不会报错 + * + * @param value 被转换的值 + * @return 结果 + */ + public static Byte toByte(Object value) + { + return toByte(value, null); + } + + /** + * 转换为Short
+ * 如果给定的值为null,或者转换失败,返回默认值
+ * 转换失败不会报错 + * + * @param value 被转换的值 + * @param defaultValue 转换错误时的默认值 + * @return 结果 + */ + public static Short toShort(Object value, Short defaultValue) + { + if (value == null) + { + return defaultValue; + } + if (value instanceof Short) + { + return (Short) value; + } + if (value instanceof Number) + { + return ((Number) value).shortValue(); + } + final String valueStr = toStr(value, null); + if (StringUtils.isEmpty(valueStr)) + { + return defaultValue; + } + try + { + return Short.parseShort(valueStr.trim()); + } + catch (Exception e) + { + return defaultValue; + } + } + + /** + * 转换为Short
+ * 如果给定的值为null,或者转换失败,返回默认值null
+ * 转换失败不会报错 + * + * @param value 被转换的值 + * @return 结果 + */ + public static Short toShort(Object value) + { + return toShort(value, null); + } + + /** + * 转换为Number
+ * 如果给定的值为空,或者转换失败,返回默认值
+ * 转换失败不会报错 + * + * @param value 被转换的值 + * @param defaultValue 转换错误时的默认值 + * @return 结果 + */ + public static Number toNumber(Object value, Number defaultValue) + { + if (value == null) + { + return defaultValue; + } + if (value instanceof Number) + { + return (Number) value; + } + final String valueStr = toStr(value, null); + if (StringUtils.isEmpty(valueStr)) + { + return defaultValue; + } + try + { + return NumberFormat.getInstance().parse(valueStr); + } + catch (Exception e) + { + return defaultValue; + } + } + + /** + * 转换为Number
+ * 如果给定的值为空,或者转换失败,返回默认值null
+ * 转换失败不会报错 + * + * @param value 被转换的值 + * @return 结果 + */ + public static Number toNumber(Object value) + { + return toNumber(value, null); + } + + /** + * 转换为int
+ * 如果给定的值为空,或者转换失败,返回默认值
+ * 转换失败不会报错 + * + * @param value 被转换的值 + * @param defaultValue 转换错误时的默认值 + * @return 结果 + */ + public static Integer toInt(Object value, Integer defaultValue) + { + if (value == null) + { + return defaultValue; + } + if (value instanceof Integer) + { + return (Integer) value; + } + if (value instanceof Number) + { + return ((Number) value).intValue(); + } + final String valueStr = toStr(value, null); + if (StringUtils.isEmpty(valueStr)) + { + return defaultValue; + } + try + { + return Integer.parseInt(valueStr.trim()); + } + catch (Exception e) + { + return defaultValue; + } + } + + /** + * 转换为int
+ * 如果给定的值为null,或者转换失败,返回默认值null
+ * 转换失败不会报错 + * + * @param value 被转换的值 + * @return 结果 + */ + public static Integer toInt(Object value) + { + return toInt(value, null); + } + + /** + * 转换为Integer数组
+ * + * @param str 被转换的值 + * @return 结果 + */ + public static Integer[] toIntArray(String str) + { + return toIntArray(",", str); + } + + /** + * 转换为Long数组
+ * + * @param str 被转换的值 + * @return 结果 + */ + public static Long[] toLongArray(String str) + { + return toLongArray(",", str); + } + + /** + * 转换为Integer数组
+ * + * @param split 分隔符 + * @param split 被转换的值 + * @return 结果 + */ + public static Integer[] toIntArray(String split, String str) + { + if (StringUtils.isEmpty(str)) + { + return new Integer[] {}; + } + String[] arr = str.split(split); + final Integer[] ints = new Integer[arr.length]; + for (int i = 0; i < arr.length; i++) + { + final Integer v = toInt(arr[i], 0); + ints[i] = v; + } + return ints; + } + + /** + * 转换为Long数组
+ * + * @param split 分隔符 + * @param str 被转换的值 + * @return 结果 + */ + public static Long[] toLongArray(String split, String str) + { + if (StringUtils.isEmpty(str)) + { + return new Long[] {}; + } + String[] arr = str.split(split); + final Long[] longs = new Long[arr.length]; + for (int i = 0; i < arr.length; i++) + { + final Long v = toLong(arr[i], null); + longs[i] = v; + } + return longs; + } + + /** + * 转换为String数组
+ * + * @param str 被转换的值 + * @return 结果 + */ + public static String[] toStrArray(String str) + { + if (StringUtils.isEmpty(str)) + { + return new String[] {}; + } + return toStrArray(",", str); + } + + /** + * 转换为String数组
+ * + * @param split 分隔符 + * @param split 被转换的值 + * @return 结果 + */ + public static String[] toStrArray(String split, String str) + { + return str.split(split); + } + + /** + * 转换为long
+ * 如果给定的值为空,或者转换失败,返回默认值
+ * 转换失败不会报错 + * + * @param value 被转换的值 + * @param defaultValue 转换错误时的默认值 + * @return 结果 + */ + public static Long toLong(Object value, Long defaultValue) + { + if (value == null) + { + return defaultValue; + } + if (value instanceof Long) + { + return (Long) value; + } + if (value instanceof Number) + { + return ((Number) value).longValue(); + } + final String valueStr = toStr(value, null); + if (StringUtils.isEmpty(valueStr)) + { + return defaultValue; + } + try + { + // 支持科学计数法 + return new BigDecimal(valueStr.trim()).longValue(); + } + catch (Exception e) + { + return defaultValue; + } + } + + /** + * 转换为long
+ * 如果给定的值为null,或者转换失败,返回默认值null
+ * 转换失败不会报错 + * + * @param value 被转换的值 + * @return 结果 + */ + public static Long toLong(Object value) + { + return toLong(value, null); + } + + /** + * 转换为double
+ * 如果给定的值为空,或者转换失败,返回默认值
+ * 转换失败不会报错 + * + * @param value 被转换的值 + * @param defaultValue 转换错误时的默认值 + * @return 结果 + */ + public static Double toDouble(Object value, Double defaultValue) + { + if (value == null) + { + return defaultValue; + } + if (value instanceof Double) + { + return (Double) value; + } + if (value instanceof Number) + { + return ((Number) value).doubleValue(); + } + final String valueStr = toStr(value, null); + if (StringUtils.isEmpty(valueStr)) + { + return defaultValue; + } + try + { + // 支持科学计数法 + return new BigDecimal(valueStr.trim()).doubleValue(); + } + catch (Exception e) + { + return defaultValue; + } + } + + /** + * 转换为double
+ * 如果给定的值为空,或者转换失败,返回默认值null
+ * 转换失败不会报错 + * + * @param value 被转换的值 + * @return 结果 + */ + public static Double toDouble(Object value) + { + return toDouble(value, null); + } + + /** + * 转换为Float
+ * 如果给定的值为空,或者转换失败,返回默认值
+ * 转换失败不会报错 + * + * @param value 被转换的值 + * @param defaultValue 转换错误时的默认值 + * @return 结果 + */ + public static Float toFloat(Object value, Float defaultValue) + { + if (value == null) + { + return defaultValue; + } + if (value instanceof Float) + { + return (Float) value; + } + if (value instanceof Number) + { + return ((Number) value).floatValue(); + } + final String valueStr = toStr(value, null); + if (StringUtils.isEmpty(valueStr)) + { + return defaultValue; + } + try + { + return Float.parseFloat(valueStr.trim()); + } + catch (Exception e) + { + return defaultValue; + } + } + + /** + * 转换为Float
+ * 如果给定的值为空,或者转换失败,返回默认值null
+ * 转换失败不会报错 + * + * @param value 被转换的值 + * @return 结果 + */ + public static Float toFloat(Object value) + { + return toFloat(value, null); + } + + /** + * 转换为boolean
+ * String支持的值为:true、false、yes、ok、no、1、0、是、否, 如果给定的值为空,或者转换失败,返回默认值
+ * 转换失败不会报错 + * + * @param value 被转换的值 + * @param defaultValue 转换错误时的默认值 + * @return 结果 + */ + public static Boolean toBool(Object value, Boolean defaultValue) + { + if (value == null) + { + return defaultValue; + } + if (value instanceof Boolean) + { + return (Boolean) value; + } + String valueStr = toStr(value, null); + if (StringUtils.isEmpty(valueStr)) + { + return defaultValue; + } + valueStr = valueStr.trim().toLowerCase(); + switch (valueStr) + { + case "true": + case "yes": + case "ok": + case "1": + case "是": + return true; + case "false": + case "no": + case "0": + case "否": + return false; + default: + return defaultValue; + } + } + + /** + * 转换为boolean
+ * 如果给定的值为空,或者转换失败,返回默认值null
+ * 转换失败不会报错 + * + * @param value 被转换的值 + * @return 结果 + */ + public static Boolean toBool(Object value) + { + return toBool(value, null); + } + + /** + * 转换为Enum对象
+ * 如果给定的值为空,或者转换失败,返回默认值
+ * + * @param clazz Enum的Class + * @param value 值 + * @param defaultValue 默认值 + * @return Enum + */ + public static > E toEnum(Class clazz, Object value, E defaultValue) + { + if (value == null) + { + return defaultValue; + } + if (clazz.isAssignableFrom(value.getClass())) + { + @SuppressWarnings("unchecked") + E myE = (E) value; + return myE; + } + final String valueStr = toStr(value, null); + if (StringUtils.isEmpty(valueStr)) + { + return defaultValue; + } + try + { + return Enum.valueOf(clazz, valueStr); + } + catch (Exception e) + { + return defaultValue; + } + } + + /** + * 转换为Enum对象
+ * 如果给定的值为空,或者转换失败,返回默认值null
+ * + * @param clazz Enum的Class + * @param value 值 + * @return Enum + */ + public static > E toEnum(Class clazz, Object value) + { + return toEnum(clazz, value, null); + } + + /** + * 转换为BigInteger
+ * 如果给定的值为空,或者转换失败,返回默认值
+ * 转换失败不会报错 + * + * @param value 被转换的值 + * @param defaultValue 转换错误时的默认值 + * @return 结果 + */ + public static BigInteger toBigInteger(Object value, BigInteger defaultValue) + { + if (value == null) + { + return defaultValue; + } + if (value instanceof BigInteger) + { + return (BigInteger) value; + } + if (value instanceof Long) + { + return BigInteger.valueOf((Long) value); + } + final String valueStr = toStr(value, null); + if (StringUtils.isEmpty(valueStr)) + { + return defaultValue; + } + try + { + return new BigInteger(valueStr); + } + catch (Exception e) + { + return defaultValue; + } + } + + /** + * 转换为BigInteger
+ * 如果给定的值为空,或者转换失败,返回默认值null
+ * 转换失败不会报错 + * + * @param value 被转换的值 + * @return 结果 + */ + public static BigInteger toBigInteger(Object value) + { + return toBigInteger(value, null); + } + + /** + * 转换为BigDecimal
+ * 如果给定的值为空,或者转换失败,返回默认值
+ * 转换失败不会报错 + * + * @param value 被转换的值 + * @param defaultValue 转换错误时的默认值 + * @return 结果 + */ + public static BigDecimal toBigDecimal(Object value, BigDecimal defaultValue) + { + if (value == null) + { + return defaultValue; + } + if (value instanceof BigDecimal) + { + return (BigDecimal) value; + } + if (value instanceof Long) + { + return new BigDecimal((Long) value); + } + if (value instanceof Double) + { + return BigDecimal.valueOf((Double) value); + } + if (value instanceof Integer) + { + return new BigDecimal((Integer) value); + } + final String valueStr = toStr(value, null); + if (StringUtils.isEmpty(valueStr)) + { + return defaultValue; + } + try + { + return new BigDecimal(valueStr); + } + catch (Exception e) + { + return defaultValue; + } + } + + /** + * 转换为BigDecimal
+ * 如果给定的值为空,或者转换失败,返回默认值
+ * 转换失败不会报错 + * + * @param value 被转换的值 + * @return 结果 + */ + public static BigDecimal toBigDecimal(Object value) + { + return toBigDecimal(value, null); + } + + /** + * 将对象转为字符串
+ * 1、Byte数组和ByteBuffer会被转换为对应字符串的数组 2、对象数组会调用Arrays.toString方法 + * + * @param obj 对象 + * @return 字符串 + */ + public static String utf8Str(Object obj) + { + return str(obj, StandardCharsets.UTF_8); + } + + /** + * 将对象转为字符串
+ * 1、Byte数组和ByteBuffer会被转换为对应字符串的数组 2、对象数组会调用Arrays.toString方法 + * + * @param obj 对象 + * @param charsetName 字符集 + * @return 字符串 + */ + public static String str(Object obj, String charsetName) + { + return str(obj, Charset.forName(charsetName)); + } + + /** + * 将对象转为字符串
+ * 1、Byte数组和ByteBuffer会被转换为对应字符串的数组 2、对象数组会调用Arrays.toString方法 + * + * @param obj 对象 + * @param charset 字符集 + * @return 字符串 + */ + public static String str(Object obj, Charset charset) + { + if (null == obj) + { + return null; + } + + if (obj instanceof String) + { + return (String) obj; + } + else if (obj instanceof byte[] || obj instanceof Byte[]) + { + if (obj instanceof byte[]) + { + return str((byte[]) obj, charset); + } + else + { + Byte[] bytes = (Byte[]) obj; + int length = bytes.length; + byte[] dest = new byte[length]; + for (int i = 0; i < length; i++) + { + dest[i] = bytes[i]; + } + return str(dest, charset); + } + } + else if (obj instanceof ByteBuffer) + { + return str((ByteBuffer) obj, charset); + } + return obj.toString(); + } + + /** + * 将byte数组转为字符串 + * + * @param bytes byte数组 + * @param charset 字符集 + * @return 字符串 + */ + public static String str(byte[] bytes, String charset) + { + return str(bytes, StringUtils.isEmpty(charset) ? Charset.defaultCharset() : Charset.forName(charset)); + } + + /** + * 解码字节码 + * + * @param data 字符串 + * @param charset 字符集,如果此字段为空,则解码的结果取决于平台 + * @return 解码后的字符串 + */ + public static String str(byte[] data, Charset charset) + { + if (data == null) + { + return null; + } + + if (null == charset) + { + return new String(data); + } + return new String(data, charset); + } + + /** + * 将编码的byteBuffer数据转换为字符串 + * + * @param data 数据 + * @param charset 字符集,如果为空使用当前系统字符集 + * @return 字符串 + */ + public static String str(ByteBuffer data, String charset) + { + if (data == null) + { + return null; + } + + return str(data, Charset.forName(charset)); + } + + /** + * 将编码的byteBuffer数据转换为字符串 + * + * @param data 数据 + * @param charset 字符集,如果为空使用当前系统字符集 + * @return 字符串 + */ + public static String str(ByteBuffer data, Charset charset) + { + if (null == charset) + { + charset = Charset.defaultCharset(); + } + return charset.decode(data).toString(); + } + + // ----------------------------------------------------------------------- 全角半角转换 + /** + * 半角转全角 + * + * @param input String. + * @return 全角字符串. + */ + public static String toSBC(String input) + { + return toSBC(input, null); + } + + /** + * 半角转全角 + * + * @param input String + * @param notConvertSet 不替换的字符集合 + * @return 全角字符串. + */ + public static String toSBC(String input, Set notConvertSet) + { + char[] c = input.toCharArray(); + for (int i = 0; i < c.length; i++) + { + if (null != notConvertSet && notConvertSet.contains(c[i])) + { + // 跳过不替换的字符 + continue; + } + + if (c[i] == ' ') + { + c[i] = '\u3000'; + } + else if (c[i] < '\177') + { + c[i] = (char) (c[i] + 65248); + + } + } + return new String(c); + } + + /** + * 全角转半角 + * + * @param input String. + * @return 半角字符串 + */ + public static String toDBC(String input) + { + return toDBC(input, null); + } + + /** + * 替换全角为半角 + * + * @param text 文本 + * @param notConvertSet 不替换的字符集合 + * @return 替换后的字符 + */ + public static String toDBC(String text, Set notConvertSet) + { + char[] c = text.toCharArray(); + for (int i = 0; i < c.length; i++) + { + if (null != notConvertSet && notConvertSet.contains(c[i])) + { + // 跳过不替换的字符 + continue; + } + + if (c[i] == '\u3000') + { + c[i] = ' '; + } + else if (c[i] > '\uFF00' && c[i] < '\uFF5F') + { + c[i] = (char) (c[i] - 65248); + } + } + return new String(c); + } + + /** + * 数字金额大写转换 先写个完整的然后将如零拾替换成零 + * + * @param n 数字 + * @return 中文大写数字 + */ + public static String digitUppercase(double n) + { + String[] fraction = { "角", "分" }; + String[] digit = { "零", "壹", "贰", "叁", "肆", "伍", "陆", "柒", "捌", "玖" }; + String[][] unit = { { "元", "万", "亿" }, { "", "拾", "佰", "仟" } }; + + String head = n < 0 ? "负" : ""; + n = Math.abs(n); + + String s = ""; + for (int i = 0; i < fraction.length; i++) + { + // 优化double计算精度丢失问题 + BigDecimal nNum = new BigDecimal(n); + BigDecimal decimal = new BigDecimal(10); + BigDecimal scale = nNum.multiply(decimal).setScale(2, RoundingMode.HALF_EVEN); + double d = scale.doubleValue(); + s += (digit[(int) (Math.floor(d * Math.pow(10, i)) % 10)] + fraction[i]).replaceAll("(零.)+", ""); + } + if (s.length() < 1) + { + s = "整"; + } + int integerPart = (int) Math.floor(n); + + for (int i = 0; i < unit[0].length && integerPart > 0; i++) + { + String p = ""; + for (int j = 0; j < unit[1].length && n > 0; j++) + { + p = digit[integerPart % 10] + unit[1][j] + p; + integerPart = integerPart / 10; + } + s = p.replaceAll("(零.)*零$", "").replaceAll("^$", "零") + unit[0][i] + s; + } + return head + s.replaceAll("(零.)*零元", "元").replaceFirst("(零.)+", "").replaceAll("(零.)+", "零").replaceAll("^整$", "零元整"); + } +} diff --git a/sdk/backend/oauth2-login-sdk/src/main/java/org/lingniu/sdk/config/SdkRedisConfig.java b/sdk/backend/oauth2-login-sdk/src/main/java/org/lingniu/sdk/config/SdkRedisConfig.java index 8081df3..f760255 100644 --- a/sdk/backend/oauth2-login-sdk/src/main/java/org/lingniu/sdk/config/SdkRedisConfig.java +++ b/sdk/backend/oauth2-login-sdk/src/main/java/org/lingniu/sdk/config/SdkRedisConfig.java @@ -3,8 +3,6 @@ package org.lingniu.sdk.config; import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator; -import org.lingniu.sdk.common.redis.RedisCache; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -60,9 +58,4 @@ public class SdkRedisConfig { template.afterPropertiesSet(); return template; } - @Bean - @ConditionalOnMissingBean(name="redisCache") - public RedisCache redisCache(@Qualifier("redisTemplate")RedisTemplate redisTemplate){ - return new RedisCache(redisTemplate); - } } \ No newline at end of file diff --git a/sdk/backend/oauth2-login-sdk/src/main/java/org/lingniu/sdk/context/PermissionContextHolder.java b/sdk/backend/oauth2-login-sdk/src/main/java/org/lingniu/sdk/context/PermissionContextHolder.java new file mode 100644 index 0000000..078805f --- /dev/null +++ b/sdk/backend/oauth2-login-sdk/src/main/java/org/lingniu/sdk/context/PermissionContextHolder.java @@ -0,0 +1,27 @@ +package org.lingniu.sdk.context; + +import org.lingniu.sdk.common.convert.Convert; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; + +/** + * 权限信息 + * + * @author ruoyi + */ +public class PermissionContextHolder +{ + private static final String PERMISSION_CONTEXT_ATTRIBUTES = "PERMISSION_CONTEXT"; + + public static void setContext(String permission) + { + RequestContextHolder.currentRequestAttributes().setAttribute(PERMISSION_CONTEXT_ATTRIBUTES, permission, + RequestAttributes.SCOPE_REQUEST); + } + + public static String getContext() + { + return Convert.toStr(RequestContextHolder.currentRequestAttributes().getAttribute(PERMISSION_CONTEXT_ATTRIBUTES, + RequestAttributes.SCOPE_REQUEST)); + } +} diff --git a/sdk/backend/oauth2-login-sdk/src/main/java/org/lingniu/sdk/service/PermissionService.java b/sdk/backend/oauth2-login-sdk/src/main/java/org/lingniu/sdk/service/PermissionService.java new file mode 100644 index 0000000..589cfb6 --- /dev/null +++ b/sdk/backend/oauth2-login-sdk/src/main/java/org/lingniu/sdk/service/PermissionService.java @@ -0,0 +1,158 @@ +package org.lingniu.sdk.service;//package com.ruoyi.framework.web.service; + +import org.lingniu.sdk.constant.Constants; +import org.lingniu.sdk.context.PermissionContextHolder; +import org.lingniu.sdk.model.user.UserInfo; +import org.lingniu.sdk.utils.UserUtil; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; + +import java.util.Set; + +/** + * RuoYi首创 自定义权限实现,ss取自SpringSecurity首字母 + * + * @author ruoyi + */ +@Service("ss") +public class PermissionService +{ + /** + * 验证用户是否具备某权限 + * + * @param permission 权限字符串 + * @return 用户是否具备某权限 + */ + public boolean hasPermi(String permission) + { + if (!StringUtils.hasText(permission)) + { + return false; + } + UserInfo userInfo = UserUtil.getUserInfo(); + if (userInfo==null || CollectionUtils.isEmpty(userInfo.getPermissions())) + { + return false; + } + PermissionContextHolder.setContext(permission); + return hasPermissions(userInfo.getPermissions(), permission); + } + + /** + * 验证用户是否不具备某权限,与 hasPermi逻辑相反 + * + * @param permission 权限字符串 + * @return 用户是否不具备某权限 + */ + public boolean lacksPermi(String permission) + { + return hasPermi(permission) != true; + } + + /** + * 验证用户是否具有以下任意一个权限 + * + * @param permissions 以 PERMISSION_DELIMITER 为分隔符的权限列表 + * @return 用户是否具有以下任意一个权限 + */ + public boolean hasAnyPermi(String permissions) + { + if (StringUtils.isEmpty(permissions)) + { + return false; + } + UserInfo userInfo = UserUtil.getUserInfo(); + if (userInfo==null || CollectionUtils.isEmpty(userInfo.getPermissions())) + { + return false; + } + PermissionContextHolder.setContext(permissions); + Set authorities = userInfo.getPermissions(); + for (String permission : permissions.split(Constants.PERMISSION_DELIMITER)) + { + if (hasPermissions(authorities, permission)) + { + return true; + } + } + return false; + } + + /** + * 判断用户是否拥有某个角色 + * + * @param role 角色字符串 + * @return 用户是否具备某角色 + */ + public boolean hasRole(String role) + { + if (StringUtils.hasLength(role)) + { + return false; + } + UserInfo userInfo = UserUtil.getUserInfo(); + if (userInfo==null || CollectionUtils.isEmpty(userInfo.getRoles())) + { + return false; + } + for (String sysRole : userInfo.getRoles()) + { + if (Constants.SUPER_ADMIN.equals(sysRole) || sysRole.equals(StringUtils.trimAllWhitespace(role))) + { + return true; + } + } + return false; + } + + /** + * 验证用户是否不具备某角色,与 isRole逻辑相反。 + * + * @param role 角色名称 + * @return 用户是否不具备某角色 + */ + public boolean lacksRole(String role) + { + return hasRole(role) != true; + } + + /** + * 验证用户是否具有以下任意一个角色 + * + * @param roles 以 ROLE_DELIMITER 为分隔符的角色列表 + * @return 用户是否具有以下任意一个角色 + */ + public boolean hasAnyRoles(String roles) + { + if (StringUtils.isEmpty(roles)) + { + return false; + } + UserInfo userInfo = UserUtil.getUserInfo(); + if (userInfo==null|| CollectionUtils.isEmpty(userInfo.getRoles())) + { + return false; + } + for (String role : roles.split(Constants.ROLE_DELIMITER)) + { + if (hasRole(role)) + { + return true; + } + } + return false; + } + + /** + * 判断是否包含权限 + * + * @param permissions 权限列表 + * @param permission 权限字符串 + * @return 用户是否具备某权限 + */ + private boolean hasPermissions(Set permissions, String permission) + { + return permissions.contains(Constants.ALL_PERMISSION) || permissions.contains(StringUtils.trimAllWhitespace(permission)); + } +} diff --git a/sdk/backend/oauth2-login-sdk/src/main/java/org/lingniu/sdk/service/RedisAccessTokenService.java b/sdk/backend/oauth2-login-sdk/src/main/java/org/lingniu/sdk/service/RedisAccessTokenService.java index fa3a1fc..cb711fd 100644 --- a/sdk/backend/oauth2-login-sdk/src/main/java/org/lingniu/sdk/service/RedisAccessTokenService.java +++ b/sdk/backend/oauth2-login-sdk/src/main/java/org/lingniu/sdk/service/RedisAccessTokenService.java @@ -1,89 +1,225 @@ package org.lingniu.sdk.service; import lombok.extern.slf4j.Slf4j; -import org.lingniu.sdk.common.redis.RedisCache; import org.lingniu.sdk.constant.CacheConstants; import org.lingniu.sdk.model.token.AccessTokenInfo; -import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; import java.time.Instant; import java.util.Map; +import java.util.concurrent.TimeUnit; @Component @Slf4j public class RedisAccessTokenService { - @Autowired - private RedisCache redisCache; - - private final long ACCESS_TOKEN_EXPIRE = 3600; // 1小时 + private final RedisTemplate redisTemplate; + public RedisAccessTokenService(RedisTemplate sdkRedisTemplate) { + this.redisTemplate = sdkRedisTemplate; + } /** * 存储Access Token到Redis */ public void storeAccessToken(AccessTokenInfo tokenInfo) { + if (tokenInfo == null || tokenInfo.getTokenValue() == null) { + log.error("tokenInfo或tokenValue为空"); + return; + } + String key = String.format(CacheConstants.ACCESS_TOKEN_KEY, tokenInfo.getTokenValue()); try { - redisCache.setCacheMap(key,tokenInfo.toMap()); + Map tokenMap = tokenInfo.toMap(); + + // 使用Hash类型存储 + redisTemplate.opsForHash().putAll(key, tokenMap); + + // 设置过期时间 Instant expiresAt = tokenInfo.getExpiresAt(); - long expire = ACCESS_TOKEN_EXPIRE; - if(expiresAt!=null){ - expire = expiresAt.getEpochSecond() - Instant.now().getEpochSecond(); + // 1小时 + long expireSeconds = 3600; + if (expiresAt != null) { + expireSeconds = expiresAt.getEpochSecond() - Instant.now().getEpochSecond(); + if (expireSeconds <= 0) { + log.warn("token已过期,不再存储: {}", tokenInfo.getTokenValue()); + redisTemplate.delete(key); // 删除可能已存在的key + return; + } } - redisCache.expire(key,expire); + redisTemplate.expire(key, expireSeconds, TimeUnit.SECONDS); + log.debug("存储Access Token成功: key={}, expireSeconds={}", key, expireSeconds); + } catch (Exception e) { - log.error("存储Access Token失败", e); + log.error("存储Access Token失败: token={}", tokenInfo.getTokenValue(), e); } } - + /** * 验证Access Token */ public boolean validateAccessToken(String token) { - String key = String.format(CacheConstants.ACCESS_TOKEN_KEY, token); - if(!redisCache.hasKey(key)){ + if (token == null || token.trim().isEmpty()) { return false; } - AccessTokenInfo accessTokenInfo = getAccessTokenInfo(token); - if(accessTokenInfo==null){ - return false; - } - return accessTokenInfo.isValid(); - } + String key = String.format(CacheConstants.ACCESS_TOKEN_KEY, token); + + // 检查key是否存在 + Boolean exists = redisTemplate.hasKey(key); + if (!exists) { + return false; + } + + // 获取token信息并验证 + AccessTokenInfo accessTokenInfo = getAccessTokenInfo(token); + return accessTokenInfo != null && accessTokenInfo.isValid(); + } /** * 删除Access Token */ public boolean removeAccessToken(String token) { + if (token == null || token.trim().isEmpty()) { + return false; + } + String key = String.format(CacheConstants.ACCESS_TOKEN_KEY, token); - return redisCache.deleteObject(key); + try { + Boolean deleted = redisTemplate.delete(key); + log.debug("删除Access Token: key={}, 结果={}", key, deleted); + return deleted; + } catch (Exception e) { + log.error("删除Access Token失败: token={}", token, e); + return false; + } } - + /** * 获取Access Token信息 */ public AccessTokenInfo getAccessTokenInfo(String token) { - String key = String.format(CacheConstants.ACCESS_TOKEN_KEY, token); - Map cacheMap = redisCache.getCacheMap(key); - if(cacheMap!=null){ - return AccessTokenInfo.fromMap(cacheMap); + if (token == null || token.trim().isEmpty()) { + return null; + } + + String key = String.format(CacheConstants.ACCESS_TOKEN_KEY, token); + try { + // 获取所有hash字段 + Map entries = redisTemplate.opsForHash().entries(key); + if (entries.isEmpty()) { + return null; + } + + // 转换为Map + Map cacheMap = convertMap(entries); + return AccessTokenInfo.fromMap(cacheMap); + + } catch (Exception e) { + log.error("获取Access Token信息失败: token={}", token, e); + return null; } - return null; } /** * 作废 删除 - * @param tokenInfo */ public void revokeAccessToken(AccessTokenInfo tokenInfo) { - if(tokenInfo==null){ + if (tokenInfo == null || tokenInfo.getTokenValue() == null) { return; } + String key = String.format(CacheConstants.ACCESS_TOKEN_KEY, tokenInfo.getTokenValue()); - redisCache.deleteObject(key); + try { + Boolean deleted = redisTemplate.delete(key); + log.debug("作废Access Token: key={}, 结果={}", key, deleted); + } catch (Exception e) { + log.error("作废Access Token失败: token={}", tokenInfo.getTokenValue(), e); + } + } + + /** + * 刷新Access Token过期时间 + */ + public boolean refreshTokenExpire(String token, long expireSeconds) { + if (token == null || token.trim().isEmpty() || expireSeconds <= 0) { + return false; + } + + String key = String.format(CacheConstants.ACCESS_TOKEN_KEY, token); + try { + Boolean exists = redisTemplate.hasKey(key); + if (exists) { + Boolean refreshed = redisTemplate.expire(key, expireSeconds, TimeUnit.SECONDS); + log.debug("刷新Token过期时间: key={}, expireSeconds={}, 结果={}", + key, expireSeconds, refreshed); + return refreshed; + } + return false; + } catch (Exception e) { + log.error("刷新Token过期时间失败: token={}", token, e); + return false; + } + } + + /** + * 获取Token剩余生存时间 + */ + public Long getTokenTtl(String token) { + if (token == null || token.trim().isEmpty()) { + return null; + } + + String key = String.format(CacheConstants.ACCESS_TOKEN_KEY, token); + try { + return redisTemplate.getExpire(key, TimeUnit.SECONDS); + } catch (Exception e) { + log.error("获取Token剩余生存时间失败: token={}", token, e); + return null; + } + } + + /** + * 辅助方法:转换Map类型 + */ + @SuppressWarnings("unchecked") + private Map convertMap(Map originalMap) { + // 根据实际情况转换,这里假设键值都是可序列化的对象 + // 如果键都是String,值都是可序列化的对象,可以直接转换 + return (Map) (Map) originalMap; + } + + /** + * 另一种实现方式:使用StringRedisTemplate(如果需要明确的字符串类型) + * + * @Autowired + * private StringRedisTemplate stringRedisTemplate; + * + * 注意:使用StringRedisTemplate时,存储和获取Map需要额外的序列化处理 + */ + + /** + * 批量删除包含特定模式的token(可选功能) + */ + public Long batchRemoveTokens(String pattern) { + if (pattern == null || pattern.trim().isEmpty()) { + return 0L; + } + + try { + // 使用scan命令避免阻塞 + var keys = redisTemplate.keys(pattern); + if (!keys.isEmpty()) { + Long count = redisTemplate.delete(keys); + log.debug("批量删除Token: pattern={}, 删除数量={}", pattern, count); + return count; + } + return 0L; + } catch (Exception e) { + log.error("批量删除Token失败: pattern={}", pattern, e); + return 0L; + } } } \ No newline at end of file diff --git a/sdk/backend/oauth2-login-sdk/src/main/java/org/lingniu/sdk/service/RedisRefreshTokenService.java b/sdk/backend/oauth2-login-sdk/src/main/java/org/lingniu/sdk/service/RedisRefreshTokenService.java index 2454ae5..f4f7aee 100644 --- a/sdk/backend/oauth2-login-sdk/src/main/java/org/lingniu/sdk/service/RedisRefreshTokenService.java +++ b/sdk/backend/oauth2-login-sdk/src/main/java/org/lingniu/sdk/service/RedisRefreshTokenService.java @@ -1,81 +1,287 @@ package org.lingniu.sdk.service; import lombok.extern.slf4j.Slf4j; -import org.lingniu.sdk.common.redis.RedisCache; import org.lingniu.sdk.constant.CacheConstants; import org.lingniu.sdk.model.token.RefreshTokenInfo; -import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; import java.time.Instant; import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; @Component @Slf4j public class RedisRefreshTokenService { - @Autowired - private RedisCache redisCache; - - private final long REFRESH_TOKEN_EXPIRE = 30 * 24 * 3600L; // 30天 + private final RedisTemplate redisTemplate; + + public RedisRefreshTokenService(RedisTemplate sdkRedisTemplate) { + this.redisTemplate = sdkRedisTemplate; + } /** * 存储Refresh Token到Redis Hash */ public void storeRefreshToken(RefreshTokenInfo tokenInfo) { - if(tokenInfo==null){ + if (tokenInfo == null || tokenInfo.getTokenValue() == null) { + log.error("tokenInfo或tokenValue为空"); return; } - String key = String.format(CacheConstants.REFRESH_TOKEN_KEY, tokenInfo.getTokenValue()); - - redisCache.setCacheMap(key,tokenInfo.toMap()); - Instant expiresAt = tokenInfo.getExpiresAt(); - long expire = REFRESH_TOKEN_EXPIRE; - if(expiresAt!=null){ - expire = expiresAt.getEpochSecond() - Instant.now().getEpochSecond(); - } - redisCache.expire(key,expire); - // 维护用户会话列表 - String userSessionsKey = String.format(CacheConstants.USER_SESSIONS, tokenInfo.getUsername()); - redisCache.setCacheSet(userSessionsKey,tokenInfo.getTokenValue()); - redisCache.expire(userSessionsKey,expire); + try { + String key = String.format(CacheConstants.REFRESH_TOKEN_KEY, tokenInfo.getTokenValue()); + + // 使用Hash存储token信息 + Map tokenMap = tokenInfo.toMap(); + redisTemplate.opsForHash().putAll(key, tokenMap); + + // 设置过期时间 + Instant expiresAt = tokenInfo.getExpiresAt(); + // 30天 + long expireSeconds = 30 * 24 * 3600L; + if (expiresAt != null) { + expireSeconds = expiresAt.getEpochSecond() - Instant.now().getEpochSecond(); + if (expireSeconds <= 0) { + log.warn("Refresh Token已过期,不再存储: {}", tokenInfo.getTokenValue()); + redisTemplate.delete(key); + return; + } + } + + redisTemplate.expire(key, expireSeconds, TimeUnit.SECONDS); + log.debug("存储Refresh Token成功: key={}, expireSeconds={}", key, expireSeconds); + + // 维护用户会话列表 - 使用Set存储用户的refresh token + String userSessionsKey = String.format(CacheConstants.USER_SESSIONS, tokenInfo.getUsername()); + redisTemplate.opsForSet().add(userSessionsKey, tokenInfo.getTokenValue()); + redisTemplate.expire(userSessionsKey, expireSeconds, TimeUnit.SECONDS); + + } catch (Exception e) { + log.error("存储Refresh Token失败: token={}", tokenInfo.getTokenValue(), e); + } } - + /** * 获取Refresh Token信息 */ public RefreshTokenInfo getRefreshTokenInfo(String token) { - String key = String.format(CacheConstants.REFRESH_TOKEN_KEY, token); - Map cacheMap = redisCache.getCacheMap(key); - if(cacheMap!=null){ - return RefreshTokenInfo.fromMap(cacheMap); + if (token == null || token.trim().isEmpty()) { + return null; + } + + String key = String.format(CacheConstants.REFRESH_TOKEN_KEY, token); + try { + // 获取所有hash字段 + Map entries = redisTemplate.opsForHash().entries(key); + if (entries.isEmpty()) { + return null; + } + + // 转换为Map + Map cacheMap = convertMap(entries); + return RefreshTokenInfo.fromMap(cacheMap); + + } catch (Exception e) { + log.error("获取Refresh Token信息失败: token={}", token, e); + return null; } - return null; } - + /** * 更新Refresh Token最后使用时间 */ public void updateRefreshToken(RefreshTokenInfo tokenInfo) { - if(tokenInfo==null){ + if (tokenInfo == null || tokenInfo.getTokenValue() == null) { + log.error("tokenInfo或tokenValue为空"); return; } - String key = String.format(CacheConstants.REFRESH_TOKEN_KEY, tokenInfo.getTokenValue()); - redisCache.setCacheMap(key,tokenInfo.toUpdateMap()); + + try { + String key = String.format(CacheConstants.REFRESH_TOKEN_KEY, tokenInfo.getTokenValue()); + Map updateMap = tokenInfo.toUpdateMap(); + + // 更新指定字段 + for (Map.Entry entry : updateMap.entrySet()) { + redisTemplate.opsForHash().put(key, entry.getKey(), entry.getValue()); + } + + log.debug("更新Refresh Token成功: token={}", tokenInfo.getTokenValue()); + + } catch (Exception e) { + log.error("更新Refresh Token失败: token={}", tokenInfo.getTokenValue(), e); + } } /** - * 作废 删除 - * @param tokenInfo + * 作废/删除Refresh Token */ public void revokeRefreshToken(RefreshTokenInfo tokenInfo) { - if(tokenInfo==null){ + if (tokenInfo == null) { + log.warn("tokenInfo为空"); return; } - String key = String.format(CacheConstants.REFRESH_TOKEN_KEY, tokenInfo.getTokenValue()); - String userSessionsKey = String.format(CacheConstants.USER_SESSIONS, tokenInfo.getUsername()); - redisCache.deleteCacheSetValue(userSessionsKey,tokenInfo.getTokenValue()); - redisCache.deleteObject(key); + + try { + String key = String.format(CacheConstants.REFRESH_TOKEN_KEY, tokenInfo.getTokenValue()); + Boolean deleted = redisTemplate.delete(key); + + if (tokenInfo.getUsername() != null) { + // 从用户会话列表中移除 + String userSessionsKey = String.format(CacheConstants.USER_SESSIONS, tokenInfo.getUsername()); + Long removed = redisTemplate.opsForSet().remove(userSessionsKey, tokenInfo.getTokenValue()); + log.debug("从用户会话列表移除token: user={}, token={}, 移除数量={}", + tokenInfo.getUsername(), tokenInfo.getTokenValue(), removed); + } + + log.debug("作废Refresh Token: token={}, 删除结果={}", tokenInfo.getTokenValue(), deleted); + + } catch (Exception e) { + log.error("作废Refresh Token失败: token={}", tokenInfo.getTokenValue(), e); + } + } + + /** + * 验证Refresh Token是否存在 + */ + public boolean validateRefreshToken(String token) { + if (token == null || token.trim().isEmpty()) { + return false; + } + + String key = String.format(CacheConstants.REFRESH_TOKEN_KEY, token); + try { + return redisTemplate.hasKey(key); + } catch (Exception e) { + log.error("验证Refresh Token失败: token={}", token, e); + return false; + } + } + + /** + * 获取用户的所有Refresh Tokens + */ + public Long getUserRefreshTokensCount(String username) { + if (username == null || username.trim().isEmpty()) { + return 0L; + } + + String userSessionsKey = String.format(CacheConstants.USER_SESSIONS, username); + try { + Long size = redisTemplate.opsForSet().size(userSessionsKey); + return size != null ? size : 0L; + } catch (Exception e) { + log.error("获取用户Refresh Tokens数量失败: user={}", username, e); + return 0L; + } + } + + /** + * 清理用户的所有Refresh Tokens(登出所有设备) + */ + public Long revokeAllUserRefreshTokens(String username) { + if (username == null || username.trim().isEmpty()) { + return 0L; + } + + try { + String userSessionsKey = String.format(CacheConstants.USER_SESSIONS, username); + + // 获取用户的所有token + Set tokens = redisTemplate.opsForSet().members(userSessionsKey); + if (tokens == null || tokens.isEmpty()) { + return 0L; + } + + long deletedCount = 0; + for (Object tokenObj : tokens) { + if (tokenObj instanceof String token) { + String tokenKey = String.format(CacheConstants.REFRESH_TOKEN_KEY, token); + Boolean deleted = redisTemplate.delete(tokenKey); + if (deleted) { + deletedCount++; + } + } + } + + // 删除用户会话列表 + redisTemplate.delete(userSessionsKey); + + log.debug("清理用户所有Refresh Tokens: user={}, 清理数量={}", username, deletedCount); + return deletedCount; + + } catch (Exception e) { + log.error("清理用户所有Refresh Tokens失败: user={}", username, e); + return 0L; + } + } + + /** + * 获取Refresh Token剩余生存时间 + */ + public Long getRefreshTokenTtl(String token) { + if (token == null || token.trim().isEmpty()) { + return null; + } + + String key = String.format(CacheConstants.REFRESH_TOKEN_KEY, token); + try { + return redisTemplate.getExpire(key, TimeUnit.SECONDS); + } catch (Exception e) { + log.error("获取Refresh Token剩余生存时间失败: token={}", token, e); + return null; + } + } + + /** + * 刷新Refresh Token过期时间 + */ + public boolean refreshTokenExpire(String token, long expireSeconds) { + if (token == null || token.trim().isEmpty() || expireSeconds <= 0) { + return false; + } + + String key = String.format(CacheConstants.REFRESH_TOKEN_KEY, token); + try { + Boolean exists = redisTemplate.hasKey(key); + if (exists) { + Boolean refreshed = redisTemplate.expire(key, expireSeconds, TimeUnit.SECONDS); + log.debug("刷新Refresh Token过期时间: token={}, expireSeconds={}, 结果={}", + token, expireSeconds, refreshed); + return refreshed; + } + return false; + } catch (Exception e) { + log.error("刷新Refresh Token过期时间失败: token={}", token, e); + return false; + } + } + + /** + * 辅助方法:转换Map类型 + */ + @SuppressWarnings("unchecked") + private Map convertMap(Map originalMap) { + // 根据实际情况转换,这里假设键值都是可序列化的对象 + return (Map) (Map) originalMap; + } + + /** + * 检查用户会话列表中是否包含指定token + */ + public boolean isTokenInUserSessions(String username, String token) { + if (username == null || token == null) { + return false; + } + + String userSessionsKey = String.format(CacheConstants.USER_SESSIONS, username); + try { + Boolean isMember = redisTemplate.opsForSet().isMember(userSessionsKey, token); + return Boolean.TRUE.equals(isMember); + } catch (Exception e) { + log.error("检查用户会话列表失败: user={}, token={}", username, token, e); + return false; + } } } \ No newline at end of file diff --git a/sdk/backend/oauth2-login-sdk/src/main/java/org/lingniu/sdk/utils/UserUtil.java b/sdk/backend/oauth2-login-sdk/src/main/java/org/lingniu/sdk/utils/UserUtil.java new file mode 100644 index 0000000..bf80c10 --- /dev/null +++ b/sdk/backend/oauth2-login-sdk/src/main/java/org/lingniu/sdk/utils/UserUtil.java @@ -0,0 +1,17 @@ +package org.lingniu.sdk.utils; + +import org.lingniu.sdk.model.user.UserInfo; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; + +public class UserUtil { + + public static UserInfo getUserInfo(){ + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if(authentication instanceof OAuth2AuthenticationToken oAuth2AuthenticationToken){ + return (UserInfo) oAuth2AuthenticationToken.getPrincipal(); + } + return null; + } +} diff --git a/sdk/backend/oauth2-login-sdk/src/main/java/org/lingniu/sdk/web/UserController.java b/sdk/backend/oauth2-login-sdk/src/main/java/org/lingniu/sdk/web/UserController.java index c3e3630..22e0d23 100644 --- a/sdk/backend/oauth2-login-sdk/src/main/java/org/lingniu/sdk/web/UserController.java +++ b/sdk/backend/oauth2-login-sdk/src/main/java/org/lingniu/sdk/web/UserController.java @@ -28,6 +28,10 @@ public class UserController { this.oAuth2ClientProperties = oAuth2ClientProperties; this.objectMapper = objectMapper; } + @GetMapping("/getUserInfo") + public CommonResult getUserInfo(@AuthenticationPrincipal UserInfo userInfo) throws Exception { + return CommonResult.success(userInfo); + } @GetMapping("/routes") public CommonResult getUserMenu(@RegisteredOAuth2AuthorizedClient OAuth2AuthorizedClient oAuth2AuthorizedClient) throws Exception { diff --git a/sdk/frontend/oauth2-login-sdk/README.md b/sdk/frontend/oauth2-login-sdk/README.md index 24d28b8..563da32 100644 --- a/sdk/frontend/oauth2-login-sdk/README.md +++ b/sdk/frontend/oauth2-login-sdk/README.md @@ -1,10 +1,8 @@ - ## 安装 - ```bash -npm install unified-login-sdk --save +npm install oauth2-login-sdk --save # 或 -yarn add unified-login-sdk +yarn add oauth2-login-sdk ``` ## 快速开始 @@ -12,522 +10,61 @@ yarn add unified-login-sdk ### 基本使用 ```typescript -import unifiedLoginSDK from 'unified-login-sdk'; +// main.ts +import unifiedLoginSDK from "oauth2-login-sdk" // 初始化配置 unifiedLoginSDK.init({ - clientId: 'your-client-id', - authorizationEndpoint: 'https://auth.example.com/authorize', - tokenEndpoint: 'https://auth.example.com/token', - userInfoEndpoint: 'https://auth.example.com/userinfo', - redirectUri: 'https://your-app.example.com/callback', - storageType: 'localStorage', - autoRefreshToken: true, - tenantId: 'your-tenant-id' // 可选,会自动添加到请求头中的tenant-id字段 -}); - -// 登录 -document.getElementById('login-btn')?.addEventListener('click', () => { - unifiedLoginSDK.login(); -}); - -// 处理回调 -if (unifiedLoginSDK.isAuthenticated()) { - // 已登录,获取用户信息 - unifiedLoginSDK.getUserInfo().then(userInfo => { - console.log('User info:', userInfo); - }); -} else if (unifiedLoginSDK.isCallback()) { - // 处理授权回调 - unifiedLoginSDK.handleCallback().then(userInfo => { - console.log('Login successful:', userInfo); - // 跳转到首页 - window.location.href = '/'; - }).catch(error => { - console.error('Login failed:', error); - }); -} - -// 退出登录 -document.getElementById('logout-btn')?.addEventListener('click', () => { - unifiedLoginSDK.logout().then(() => { - console.log('Logout successful'); - window.location.href = '/login'; - }); -}); + clientId: import.meta.env.VITE_APP_CLIENT_ID, + registrationId: import.meta.env.VITE_APP_REGISTRATION_ID, + storageType: import.meta.env.VITE_APP_STORAGE_TYPE, + basepath: import.meta.env.VITE_APP_BASE_API, + idpLogoutUrl: import.meta.env.VITE_APP_IDP_LOGOUT_URL, + homePage: import.meta.env.VITE_APP_HOME_PAGE +}) +``` +```properties +# 配置文件 +VITE_APP_CLIENT_ID=xxx +VITE_APP_REGISTRATION_ID=xxx +VITE_APP_STORAGE_TYPE=localStorage +VITE_APP_IDP_LOGOUT_URL=http://106.14.217.120/idp-ui/logout +VITE_APP_HOME_PAGE=http://106.14.217.120/portal-ui/index ``` - -## 核心功能 - -### 初始化配置 - ```typescript -unifiedLoginSDK.init({ - clientId: 'your-client-id', - clientSecret: 'your-client-secret', // 可选,某些场景下需要 - authorizationEndpoint: 'https://auth.example.com/authorize', - tokenEndpoint: 'https://auth.example.com/token', - userInfoEndpoint: 'https://auth.example.com/userinfo', - redirectUri: 'https://your-app.example.com/callback', - storageType: 'localStorage', // 可选,默认localStorage - autoRefreshToken: true, // 可选,默认true - permissionsEndpoint: 'https://auth.example.com/permissions' // 可选,权限端点 -}); -``` - -### 登录流程 - -1. 调用`login()`方法跳转到授权页面 -2. 用户在授权页面登录并授权 -3. 授权服务器重定向到配置的`redirectUri` -4. 调用`handleCallback()`方法处理授权回调,获取用户信息 - -### Token管理 - -```typescript -// 获取访问令牌 -const accessToken = unifiedLoginSDK.getAccessToken(); - -// 刷新令牌 -unifiedLoginSDK.refreshToken().then(() => { - console.log('Token refreshed'); -}).catch(error => { - console.error('Failed to refresh token:', error); -}); - -// 检查是否已认证 -const isAuthenticated = unifiedLoginSDK.isAuthenticated(); -``` - -### 用户信息管理 - -```typescript -// 获取用户信息 -unifiedLoginSDK.getUserInfo().then(userInfo => { - console.log('User info:', userInfo); -}); - -// 获取用户权限列表 -unifiedLoginSDK.getPermissions().then(permissions => { - console.log('Permissions:', permissions); -}); -``` - -### 事件监听 - -```typescript -// 监听登录事件 -unifiedLoginSDK.on('login', () => { - console.log('User logged in'); -}); - -// 监听退出事件 -unifiedLoginSDK.on('logout', () => { - console.log('User logged out'); -}); - -// 监听Token过期事件 -unifiedLoginSDK.on('tokenExpired', () => { - console.log('Token expired'); - // 可以在这里执行自定义逻辑,如跳转到登录页 - unifiedLoginSDK.login(); -}); - -// 移除事件监听 -const handleLogin = () => console.log('User logged in'); -unifiedLoginSDK.on('login', handleLogin); -unifiedLoginSDK.off('login', handleLogin); -``` - -## 框架集成 - -### Vue 2 - -```javascript -// main.js -import Vue from 'vue'; -import { createVuePlugin } from 'unified-login-sdk'; -import App from './App.vue'; -import router from './router'; - -// 创建Vue插件 -const vuePlugin = createVuePlugin('localStorage'); - -// 安装插件 -Vue.use(vuePlugin, { - config: { - clientId: 'your-client-id', - authorizationEndpoint: 'https://auth.example.com/authorize', - tokenEndpoint: 'https://auth.example.com/token', - userInfoEndpoint: 'https://auth.example.com/userinfo', - redirectUri: 'https://your-app.example.com/callback' - } -}); - -new Vue({ - router, - render: h => h(App) -}).$mount('#app'); -``` - -在组件中使用: - -```vue - - - -``` - -### Vue 3 - -```javascript -// main.js -import { createApp } from 'vue'; -import { createVuePlugin } from 'unified-login-sdk'; -import App from './App.vue'; -import router from './router'; - -// 创建Vue插件 -const vuePlugin = createVuePlugin('localStorage'); - -const app = createApp(App); - -// 安装插件 -app.use(vuePlugin, { - config: { - clientId: 'your-client-id', - authorizationEndpoint: 'https://auth.example.com/authorize', - tokenEndpoint: 'https://auth.example.com/token', - userInfoEndpoint: 'https://auth.example.com/userinfo', - redirectUri: 'https://your-app.example.com/callback' - } -}); - -app.use(router); -app.mount('#app'); -``` - -在组件中使用(Composition API): - -```vue - - - -``` -``` - -## API参考 - -### 初始化 - -```typescript -init(config: SDKConfig): void -``` - -初始化SDK配置。 - -### 登录 - -```typescript -login(redirectUri?: string): void -``` - -触发登录流程,可选参数`redirectUri`可覆盖初始化时的配置。 - -### 退出登录 - -```typescript -logout(): Promise -``` - -退出登录,清除本地存储的Token和用户信息。 - -### 处理授权回调 - -```typescript -handleCallback(): Promise -``` - -处理授权回调,获取用户信息。 - -### 获取用户信息 - -```typescript -getUserInfo(): Promise -``` - -获取用户基本信息。 - -### 获取用户权限列表 - -```typescript -getPermissions(): Promise -``` - -获取用户权限列表。 - -### 检查是否已认证 - -```typescript -isAuthenticated(): boolean -``` - -检查用户是否已认证。 - -### 获取访问令牌 - -```typescript -getAccessToken(): string | null -``` - -获取访问令牌。 - -### 刷新访问令牌 - -```typescript -refreshToken(): Promise -``` - -刷新访问令牌。 - -### 事件监听 - -```typescript -on(event: 'login' | 'logout' | 'tokenExpired', callback: Function): void -``` - -监听登录、退出或Token过期事件。 - -### 移除事件监听 - -```typescript -off(event: 'login' | 'logout' | 'tokenExpired', callback: Function): void -``` - -移除事件监听。 - -## 配置选项 - -| 选项 | 类型 | 必填 | 默认值 | 描述 | -|------|------|------|--------|------| -| clientId | string | 是 | - | 客户端ID | -| clientSecret | string | 否 | - | 客户端密钥,某些场景下需要 | -| authorizationEndpoint | string | 是 | - | 授权端点URL | -| tokenEndpoint | string | 是 | - | Token端点URL | -| userInfoEndpoint | string | 是 | - | 用户信息端点URL | -| redirectUri | string | 是 | - | 重定向URL | -| storageType | 'localStorage' 'sessionStorage' 'cookie' | 否 | 'localStorage' | Token存储类型 | -| autoRefreshToken | boolean | 否 | true | 是否自动刷新Token | -| permissionsEndpoint | string | 否 | - | 权限端点URL | -| stateLength | number | 否 | 32 | 状态参数长度 | -| tenantId | string | 否 | - | 租户ID,会自动添加到请求头中的tenant-id字段 | - -## 事件处理 - -| 事件 | 描述 | -|------|------| -| login | 用户登录成功时触发 | -| logout | 用户退出登录时触发 | -| tokenExpired | Token过期时触发 | - -## 路由守卫 - -### Vue路由守卫 - -```javascript -// router/index.js -import VueRouter from 'vue-router'; -import { Auth } from 'unified-login-sdk'; -import { Storage } from 'unified-login-sdk'; -import { RouterGuard } from 'unified-login-sdk'; - -const storage = new Storage('localStorage'); -const auth = new Auth(storage); -const routerGuard = new RouterGuard(auth); - -const router = new VueRouter({ - routes: [ - { - path: '/', - name: 'Home', - component: Home - }, - { - path: '/protected', - name: 'Protected', - component: Protected, - meta: { - auth: { - requiresAuth: true, - requiredPermissions: ['read:protected'] +// 配置路由导航守卫 +router.beforeEach(async (to, _from, next) => { + // 打开页面 判断是已认证 + if (!unifiedLoginSDK.isAuthenticated()) { + // 未认证 + if (to.path === '/oauth2/callback') { + // 如果是登录回调 进行回调登录 + await unifiedLoginSDK.handleCallback() + }else{ + // 跳转登录 + await unifiedLoginSDK.login() } - } + } else { + //已认证 打开页面 + next() } - ] -}); +}) -// 添加路由守卫 -router.beforeEach(routerGuard.createVueGuard()); - -export default router; ``` - - - -## 错误处理 - -### 网络错误处理 - ```typescript -try { - await unifiedLoginSDK.getUserInfo(); -} catch (error) { - if (error.name === 'HttpError') { - // 处理HTTP错误 - console.error('HTTP Error:', error.status, error.message); - if (error.status === 401) { - // 未授权,跳转到登录页 - unifiedLoginSDK.login(); - } else if (error.status === 403) { - // 权限不足 - window.location.href = '/403'; +// 请求后端接口添加token +const service = axios.create({ + // axios中请求配置有baseURL选项,表示请求URL公共部分 + baseURL: import.meta.env.VITE_APP_BASE_API, + // 超时 + timeout: 10000 +}) + +// request拦截器 +import unifiedLoginSDK from "oauth2-login-sdk" +service.interceptors.request.use((config: any) => { + if (getToken() && !isToken) { + config.headers['Authorization'] = unifiedLoginSDK.getToken() } - } else { - // 处理其他错误 - console.error('Error:', error.message); - } -} +}) ``` - -### Token失效处理 - -```typescript -// 监听Token过期事件 -unifiedLoginSDK.on('tokenExpired', () => { - console.log('Token expired'); - // 跳转到登录页 - unifiedLoginSDK.login(); -}); -``` - -## 最佳实践 - -1. **配置安全存储**:根据项目需求选择合适的存储类型,敏感信息建议使用cookie并设置secure和httpOnly标志。 - -2. **合理设置Token过期时间**:根据项目安全性要求设置合适的Token过期时间,建议access token过期时间较短,refresh token过期时间较长。 - -3. **使用路由守卫保护敏感路由**:对需要登录或特定权限的路由使用路由守卫进行保护。 - -4. **处理网络错误**:在调用SDK方法时,使用try-catch捕获并处理可能的错误。 - -5. **监听Token过期事件**:及时处理Token过期情况,避免用户体验下降。 - -6. **不要直接暴露clientSecret**:clientSecret应该只在后端使用,前端SDK尽量避免使用clientSecret。 - -7. **使用HTTPS**:确保所有与授权服务器的通信都使用HTTPS,避免Token被窃取。 - -8. **定期清理存储**:在用户退出登录时,确保清理所有相关存储的信息。 - -## 浏览器兼容性 - -- Chrome (推荐) -- Firefox -- Safari -- Edge - -## 许可证 - -MIT License