更新文档
This commit is contained in:
@@ -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<UserInfo> getUserInfo(@AuthenticationPrincipal UserInfo userInfo) throws Exception {
|
||||||
|
return CommonResult.success(userInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
105
sdk/backend/oauth2-login-sdk/README.md
Normal file
105
sdk/backend/oauth2-login-sdk/README.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# 一、使用说明
|
||||||
|
1.引入依赖
|
||||||
|
```xml
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.lingniu</groupId>
|
||||||
|
<artifactId>oauth2-login-sdk</artifactId>
|
||||||
|
<version>1.0-SNAPSHOT</version>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
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提供了许多默认表达式
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
结合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 有权访问";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
BIN
sdk/backend/oauth2-login-sdk/img.png
Normal file
BIN
sdk/backend/oauth2-login-sdk/img.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 135 KiB |
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,6 @@ package org.lingniu.sdk.config;
|
|||||||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
|
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.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
@@ -60,9 +58,4 @@ public class SdkRedisConfig {
|
|||||||
template.afterPropertiesSet();
|
template.afterPropertiesSet();
|
||||||
return template;
|
return template;
|
||||||
}
|
}
|
||||||
@Bean
|
|
||||||
@ConditionalOnMissingBean(name="redisCache")
|
|
||||||
public RedisCache redisCache(@Qualifier("redisTemplate")RedisTemplate redisTemplate){
|
|
||||||
return new RedisCache(redisTemplate);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> 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<String> permissions, String permission)
|
||||||
|
{
|
||||||
|
return permissions.contains(Constants.ALL_PERMISSION) || permissions.contains(StringUtils.trimAllWhitespace(permission));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,89 +1,225 @@
|
|||||||
package org.lingniu.sdk.service;
|
package org.lingniu.sdk.service;
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.lingniu.sdk.common.redis.RedisCache;
|
|
||||||
import org.lingniu.sdk.constant.CacheConstants;
|
import org.lingniu.sdk.constant.CacheConstants;
|
||||||
import org.lingniu.sdk.model.token.AccessTokenInfo;
|
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 org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class RedisAccessTokenService {
|
public class RedisAccessTokenService {
|
||||||
|
|
||||||
@Autowired
|
private final RedisTemplate<String, Object> redisTemplate;
|
||||||
private RedisCache redisCache;
|
|
||||||
|
|
||||||
private final long ACCESS_TOKEN_EXPIRE = 3600; // 1小时
|
|
||||||
|
|
||||||
|
public RedisAccessTokenService(RedisTemplate<String, Object> sdkRedisTemplate) {
|
||||||
|
this.redisTemplate = sdkRedisTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 存储Access Token到Redis
|
* 存储Access Token到Redis
|
||||||
*/
|
*/
|
||||||
public void storeAccessToken(AccessTokenInfo tokenInfo) {
|
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());
|
String key = String.format(CacheConstants.ACCESS_TOKEN_KEY, tokenInfo.getTokenValue());
|
||||||
try {
|
try {
|
||||||
redisCache.setCacheMap(key,tokenInfo.toMap());
|
Map<String, Object> tokenMap = tokenInfo.toMap();
|
||||||
|
|
||||||
|
// 使用Hash类型存储
|
||||||
|
redisTemplate.opsForHash().putAll(key, tokenMap);
|
||||||
|
|
||||||
|
// 设置过期时间
|
||||||
Instant expiresAt = tokenInfo.getExpiresAt();
|
Instant expiresAt = tokenInfo.getExpiresAt();
|
||||||
long expire = ACCESS_TOKEN_EXPIRE;
|
// 1小时
|
||||||
if(expiresAt!=null){
|
long expireSeconds = 3600;
|
||||||
expire = expiresAt.getEpochSecond() - Instant.now().getEpochSecond();
|
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) {
|
} catch (Exception e) {
|
||||||
log.error("存储Access Token失败", e);
|
log.error("存储Access Token失败: token={}", tokenInfo.getTokenValue(), e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证Access Token
|
* 验证Access Token
|
||||||
*/
|
*/
|
||||||
public boolean validateAccessToken(String token) {
|
public boolean validateAccessToken(String token) {
|
||||||
String key = String.format(CacheConstants.ACCESS_TOKEN_KEY, token);
|
if (token == null || token.trim().isEmpty()) {
|
||||||
if(!redisCache.hasKey(key)){
|
|
||||||
return false;
|
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
|
* 删除Access Token
|
||||||
*/
|
*/
|
||||||
public boolean removeAccessToken(String token) {
|
public boolean removeAccessToken(String token) {
|
||||||
|
if (token == null || token.trim().isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
String key = String.format(CacheConstants.ACCESS_TOKEN_KEY, token);
|
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信息
|
* 获取Access Token信息
|
||||||
*/
|
*/
|
||||||
public AccessTokenInfo getAccessTokenInfo(String token) {
|
public AccessTokenInfo getAccessTokenInfo(String token) {
|
||||||
String key = String.format(CacheConstants.ACCESS_TOKEN_KEY, token);
|
if (token == null || token.trim().isEmpty()) {
|
||||||
Map<String, Object> cacheMap = redisCache.getCacheMap(key);
|
return null;
|
||||||
if(cacheMap!=null){
|
}
|
||||||
return AccessTokenInfo.fromMap(cacheMap);
|
|
||||||
|
String key = String.format(CacheConstants.ACCESS_TOKEN_KEY, token);
|
||||||
|
try {
|
||||||
|
// 获取所有hash字段
|
||||||
|
Map<Object, Object> entries = redisTemplate.opsForHash().entries(key);
|
||||||
|
if (entries.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为Map<String, Object>
|
||||||
|
Map<String, Object> 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) {
|
public void revokeAccessToken(AccessTokenInfo tokenInfo) {
|
||||||
if(tokenInfo==null){
|
if (tokenInfo == null || tokenInfo.getTokenValue() == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
String key = String.format(CacheConstants.ACCESS_TOKEN_KEY, tokenInfo.getTokenValue());
|
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<String, Object> convertMap(Map<Object, Object> originalMap) {
|
||||||
|
// 根据实际情况转换,这里假设键值都是可序列化的对象
|
||||||
|
// 如果键都是String,值都是可序列化的对象,可以直接转换
|
||||||
|
return (Map<String, Object>) (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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,81 +1,287 @@
|
|||||||
package org.lingniu.sdk.service;
|
package org.lingniu.sdk.service;
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.lingniu.sdk.common.redis.RedisCache;
|
|
||||||
import org.lingniu.sdk.constant.CacheConstants;
|
import org.lingniu.sdk.constant.CacheConstants;
|
||||||
import org.lingniu.sdk.model.token.RefreshTokenInfo;
|
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 org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class RedisRefreshTokenService {
|
public class RedisRefreshTokenService {
|
||||||
|
|
||||||
@Autowired
|
private final RedisTemplate<String, Object> redisTemplate;
|
||||||
private RedisCache redisCache;
|
|
||||||
|
public RedisRefreshTokenService(RedisTemplate<String, Object> sdkRedisTemplate) {
|
||||||
private final long REFRESH_TOKEN_EXPIRE = 30 * 24 * 3600L; // 30天
|
this.redisTemplate = sdkRedisTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 存储Refresh Token到Redis Hash
|
* 存储Refresh Token到Redis Hash
|
||||||
*/
|
*/
|
||||||
public void storeRefreshToken(RefreshTokenInfo tokenInfo) {
|
public void storeRefreshToken(RefreshTokenInfo tokenInfo) {
|
||||||
if(tokenInfo==null){
|
if (tokenInfo == null || tokenInfo.getTokenValue() == null) {
|
||||||
|
log.error("tokenInfo或tokenValue为空");
|
||||||
return;
|
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);
|
try {
|
||||||
// 维护用户会话列表
|
String key = String.format(CacheConstants.REFRESH_TOKEN_KEY, tokenInfo.getTokenValue());
|
||||||
String userSessionsKey = String.format(CacheConstants.USER_SESSIONS, tokenInfo.getUsername());
|
|
||||||
redisCache.setCacheSet(userSessionsKey,tokenInfo.getTokenValue());
|
// 使用Hash存储token信息
|
||||||
redisCache.expire(userSessionsKey,expire);
|
Map<String, String> 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信息
|
* 获取Refresh Token信息
|
||||||
*/
|
*/
|
||||||
public RefreshTokenInfo getRefreshTokenInfo(String token) {
|
public RefreshTokenInfo getRefreshTokenInfo(String token) {
|
||||||
String key = String.format(CacheConstants.REFRESH_TOKEN_KEY, token);
|
if (token == null || token.trim().isEmpty()) {
|
||||||
Map<String, Object> cacheMap = redisCache.getCacheMap(key);
|
return null;
|
||||||
if(cacheMap!=null){
|
}
|
||||||
return RefreshTokenInfo.fromMap(cacheMap);
|
|
||||||
|
String key = String.format(CacheConstants.REFRESH_TOKEN_KEY, token);
|
||||||
|
try {
|
||||||
|
// 获取所有hash字段
|
||||||
|
Map<Object, Object> entries = redisTemplate.opsForHash().entries(key);
|
||||||
|
if (entries.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为Map<String, Object>
|
||||||
|
Map<String, Object> cacheMap = convertMap(entries);
|
||||||
|
return RefreshTokenInfo.fromMap(cacheMap);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("获取Refresh Token信息失败: token={}", token, e);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新Refresh Token最后使用时间
|
* 更新Refresh Token最后使用时间
|
||||||
*/
|
*/
|
||||||
public void updateRefreshToken(RefreshTokenInfo tokenInfo) {
|
public void updateRefreshToken(RefreshTokenInfo tokenInfo) {
|
||||||
if(tokenInfo==null){
|
if (tokenInfo == null || tokenInfo.getTokenValue() == null) {
|
||||||
|
log.error("tokenInfo或tokenValue为空");
|
||||||
return;
|
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<String, String> updateMap = tokenInfo.toUpdateMap();
|
||||||
|
|
||||||
|
// 更新指定字段
|
||||||
|
for (Map.Entry<String, String> 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 作废 删除
|
* 作废/删除Refresh Token
|
||||||
* @param tokenInfo
|
|
||||||
*/
|
*/
|
||||||
public void revokeRefreshToken(RefreshTokenInfo tokenInfo) {
|
public void revokeRefreshToken(RefreshTokenInfo tokenInfo) {
|
||||||
if(tokenInfo==null){
|
if (tokenInfo == null) {
|
||||||
|
log.warn("tokenInfo为空");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String key = String.format(CacheConstants.REFRESH_TOKEN_KEY, tokenInfo.getTokenValue());
|
|
||||||
String userSessionsKey = String.format(CacheConstants.USER_SESSIONS, tokenInfo.getUsername());
|
try {
|
||||||
redisCache.deleteCacheSetValue(userSessionsKey,tokenInfo.getTokenValue());
|
String key = String.format(CacheConstants.REFRESH_TOKEN_KEY, tokenInfo.getTokenValue());
|
||||||
redisCache.deleteObject(key);
|
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<Object> 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<String, Object> convertMap(Map<Object, Object> originalMap) {
|
||||||
|
// 根据实际情况转换,这里假设键值都是可序列化的对象
|
||||||
|
return (Map<String, Object>) (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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,10 @@ public class UserController {
|
|||||||
this.oAuth2ClientProperties = oAuth2ClientProperties;
|
this.oAuth2ClientProperties = oAuth2ClientProperties;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
@GetMapping("/getUserInfo")
|
||||||
|
public CommonResult<UserInfo> getUserInfo(@AuthenticationPrincipal UserInfo userInfo) throws Exception {
|
||||||
|
return CommonResult.success(userInfo);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/routes")
|
@GetMapping("/routes")
|
||||||
public CommonResult<Object> getUserMenu(@RegisteredOAuth2AuthorizedClient OAuth2AuthorizedClient oAuth2AuthorizedClient) throws Exception {
|
public CommonResult<Object> getUserMenu(@RegisteredOAuth2AuthorizedClient OAuth2AuthorizedClient oAuth2AuthorizedClient) throws Exception {
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
```bash
|
```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
|
```typescript
|
||||||
import unifiedLoginSDK from 'unified-login-sdk';
|
// main.ts
|
||||||
|
import unifiedLoginSDK from "oauth2-login-sdk"
|
||||||
|
|
||||||
// 初始化配置
|
// 初始化配置
|
||||||
unifiedLoginSDK.init({
|
unifiedLoginSDK.init({
|
||||||
clientId: 'your-client-id',
|
clientId: import.meta.env.VITE_APP_CLIENT_ID,
|
||||||
authorizationEndpoint: 'https://auth.example.com/authorize',
|
registrationId: import.meta.env.VITE_APP_REGISTRATION_ID,
|
||||||
tokenEndpoint: 'https://auth.example.com/token',
|
storageType: import.meta.env.VITE_APP_STORAGE_TYPE,
|
||||||
userInfoEndpoint: 'https://auth.example.com/userinfo',
|
basepath: import.meta.env.VITE_APP_BASE_API,
|
||||||
redirectUri: 'https://your-app.example.com/callback',
|
idpLogoutUrl: import.meta.env.VITE_APP_IDP_LOGOUT_URL,
|
||||||
storageType: 'localStorage',
|
homePage: import.meta.env.VITE_APP_HOME_PAGE
|
||||||
autoRefreshToken: true,
|
})
|
||||||
tenantId: 'your-tenant-id' // 可选,会自动添加到请求头中的tenant-id字段
|
```
|
||||||
});
|
```properties
|
||||||
|
# 配置文件
|
||||||
// 登录
|
VITE_APP_CLIENT_ID=xxx
|
||||||
document.getElementById('login-btn')?.addEventListener('click', () => {
|
VITE_APP_REGISTRATION_ID=xxx
|
||||||
unifiedLoginSDK.login();
|
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
|
||||||
// 处理回调
|
|
||||||
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';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 核心功能
|
|
||||||
|
|
||||||
### 初始化配置
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
unifiedLoginSDK.init({
|
// 配置路由导航守卫
|
||||||
clientId: 'your-client-id',
|
router.beforeEach(async (to, _from, next) => {
|
||||||
clientSecret: 'your-client-secret', // 可选,某些场景下需要
|
// 打开页面 判断是已认证
|
||||||
authorizationEndpoint: 'https://auth.example.com/authorize',
|
if (!unifiedLoginSDK.isAuthenticated()) {
|
||||||
tokenEndpoint: 'https://auth.example.com/token',
|
// 未认证
|
||||||
userInfoEndpoint: 'https://auth.example.com/userinfo',
|
if (to.path === '/oauth2/callback') {
|
||||||
redirectUri: 'https://your-app.example.com/callback',
|
// 如果是登录回调 进行回调登录
|
||||||
storageType: 'localStorage', // 可选,默认localStorage
|
await unifiedLoginSDK.handleCallback()
|
||||||
autoRefreshToken: true, // 可选,默认true
|
}else{
|
||||||
permissionsEndpoint: 'https://auth.example.com/permissions' // 可选,权限端点
|
// 跳转登录
|
||||||
});
|
await unifiedLoginSDK.login()
|
||||||
```
|
|
||||||
|
|
||||||
### 登录流程
|
|
||||||
|
|
||||||
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
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div v-if="$auth.isAuthenticated()">
|
|
||||||
<h1>Welcome, {{ userInfo?.name }}</h1>
|
|
||||||
<button @click="logout">Logout</button>
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<button @click="login">Login</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
userInfo: null
|
|
||||||
};
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
if (this.$auth.isAuthenticated()) {
|
|
||||||
this.getUserInfo();
|
|
||||||
} else if (this.$auth.isCallback()) {
|
|
||||||
this.handleCallback();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
login() {
|
|
||||||
this.$auth.login();
|
|
||||||
},
|
|
||||||
async logout() {
|
|
||||||
await this.$auth.logout();
|
|
||||||
window.location.href = '/';
|
|
||||||
},
|
|
||||||
async getUserInfo() {
|
|
||||||
this.userInfo = await this.$auth.getUserInfo();
|
|
||||||
},
|
|
||||||
async handleCallback() {
|
|
||||||
try {
|
|
||||||
this.userInfo = await this.$auth.handleCallback();
|
|
||||||
window.location.href = '/';
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Login failed:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div v-if="isAuthenticated">
|
|
||||||
<h1>Welcome, {{ userInfo?.name }}</h1>
|
|
||||||
<button @click="logout">Logout</button>
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<button @click="login">Login</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted, inject } from 'vue';
|
|
||||||
|
|
||||||
// 注入SDK实例
|
|
||||||
const auth = inject('unifiedLogin');
|
|
||||||
const userInfo = ref(null);
|
|
||||||
const isAuthenticated = ref(auth.isAuthenticated());
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (isAuthenticated.value) {
|
|
||||||
getUserInfo();
|
|
||||||
} else if (auth.isCallback()) {
|
|
||||||
handleCallback();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const login = () => {
|
|
||||||
auth.login();
|
|
||||||
};
|
|
||||||
|
|
||||||
const logout = async () => {
|
|
||||||
await auth.logout();
|
|
||||||
window.location.href = '/';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getUserInfo = async () => {
|
|
||||||
userInfo.value = await auth.getUserInfo();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCallback = async () => {
|
|
||||||
try {
|
|
||||||
userInfo.value = await auth.handleCallback();
|
|
||||||
isAuthenticated.value = true;
|
|
||||||
window.location.href = '/';
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Login failed:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
```
|
|
||||||
|
|
||||||
## API参考
|
|
||||||
|
|
||||||
### 初始化
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
init(config: SDKConfig): void
|
|
||||||
```
|
|
||||||
|
|
||||||
初始化SDK配置。
|
|
||||||
|
|
||||||
### 登录
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
login(redirectUri?: string): void
|
|
||||||
```
|
|
||||||
|
|
||||||
触发登录流程,可选参数`redirectUri`可覆盖初始化时的配置。
|
|
||||||
|
|
||||||
### 退出登录
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
logout(): Promise<void>
|
|
||||||
```
|
|
||||||
|
|
||||||
退出登录,清除本地存储的Token和用户信息。
|
|
||||||
|
|
||||||
### 处理授权回调
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
handleCallback(): Promise<UserInfo>
|
|
||||||
```
|
|
||||||
|
|
||||||
处理授权回调,获取用户信息。
|
|
||||||
|
|
||||||
### 获取用户信息
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
getUserInfo(): Promise<UserInfo>
|
|
||||||
```
|
|
||||||
|
|
||||||
获取用户基本信息。
|
|
||||||
|
|
||||||
### 获取用户权限列表
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
getPermissions(): Promise<string[]>
|
|
||||||
```
|
|
||||||
|
|
||||||
获取用户权限列表。
|
|
||||||
|
|
||||||
### 检查是否已认证
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
isAuthenticated(): boolean
|
|
||||||
```
|
|
||||||
|
|
||||||
检查用户是否已认证。
|
|
||||||
|
|
||||||
### 获取访问令牌
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
getAccessToken(): string | null
|
|
||||||
```
|
|
||||||
|
|
||||||
获取访问令牌。
|
|
||||||
|
|
||||||
### 刷新访问令牌
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
refreshToken(): Promise<void>
|
|
||||||
```
|
|
||||||
|
|
||||||
刷新访问令牌。
|
|
||||||
|
|
||||||
### 事件监听
|
|
||||||
|
|
||||||
```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']
|
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
|
//已认证 打开页面
|
||||||
|
next()
|
||||||
}
|
}
|
||||||
]
|
})
|
||||||
});
|
|
||||||
|
|
||||||
// 添加路由守卫
|
|
||||||
router.beforeEach(routerGuard.createVueGuard());
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 错误处理
|
|
||||||
|
|
||||||
### 网络错误处理
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
try {
|
// 请求后端接口添加token
|
||||||
await unifiedLoginSDK.getUserInfo();
|
const service = axios.create({
|
||||||
} catch (error) {
|
// axios中请求配置有baseURL选项,表示请求URL公共部分
|
||||||
if (error.name === 'HttpError') {
|
baseURL: import.meta.env.VITE_APP_BASE_API,
|
||||||
// 处理HTTP错误
|
// 超时
|
||||||
console.error('HTTP Error:', error.status, error.message);
|
timeout: 10000
|
||||||
if (error.status === 401) {
|
})
|
||||||
// 未授权,跳转到登录页
|
|
||||||
unifiedLoginSDK.login();
|
// request拦截器
|
||||||
} else if (error.status === 403) {
|
import unifiedLoginSDK from "oauth2-login-sdk"
|
||||||
// 权限不足
|
service.interceptors.request.use((config: any) => {
|
||||||
window.location.href = '/403';
|
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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user