mirror of
https://gitee.com/dromara/RuoYi-Vue-Plus.git
synced 2026-04-17 07:43:15 +08:00
update 优化 客户端管理 增加白名单路径和白名单IP功能 可限制客户端能访问的具体路径与可访问的具体IP地址
This commit is contained in:
@@ -1,12 +1,16 @@
|
|||||||
package org.dromara.web.service;
|
package org.dromara.web.service;
|
||||||
|
|
||||||
|
|
||||||
|
import cn.dev33.satoken.stp.parameter.SaLoginParameter;
|
||||||
|
import cn.hutool.core.util.ObjectUtil;
|
||||||
import org.dromara.common.core.exception.ServiceException;
|
import org.dromara.common.core.exception.ServiceException;
|
||||||
import org.dromara.common.core.utils.SpringUtils;
|
import org.dromara.common.core.utils.SpringUtils;
|
||||||
import org.dromara.system.domain.SysClient;
|
import org.dromara.common.satoken.utils.LoginHelper;
|
||||||
import org.dromara.system.domain.vo.SysClientVo;
|
import org.dromara.system.domain.vo.SysClientVo;
|
||||||
import org.dromara.web.domain.vo.LoginVo;
|
import org.dromara.web.domain.vo.LoginVo;
|
||||||
|
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 授权策略
|
* 授权策略
|
||||||
*
|
*
|
||||||
@@ -34,6 +38,37 @@ public interface IAuthStrategy {
|
|||||||
return instance.login(body, client);
|
return instance.login(body, client);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按客户端配置构建统一登录参数。
|
||||||
|
*
|
||||||
|
* @param client 客户端配置
|
||||||
|
* @return Sa-Token 登录参数
|
||||||
|
*/
|
||||||
|
static SaLoginParameter buildLoginParameter(SysClientVo client) {
|
||||||
|
return buildLoginParameter(client, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按客户端配置构建统一登录参数,并预留自定义扩展入口。
|
||||||
|
*
|
||||||
|
* @param client 客户端配置
|
||||||
|
* @param customizer 自定义扩展逻辑
|
||||||
|
* @return Sa-Token 登录参数
|
||||||
|
*/
|
||||||
|
static SaLoginParameter buildLoginParameter(SysClientVo client, Consumer<SaLoginParameter> customizer) {
|
||||||
|
SaLoginParameter model = new SaLoginParameter();
|
||||||
|
model.setDeviceType(client.getDeviceType());
|
||||||
|
model.setTimeout(client.getTimeout());
|
||||||
|
model.setActiveTimeout(client.getActiveTimeout());
|
||||||
|
model.setExtra(LoginHelper.CLIENT_KEY, client.getClientId());
|
||||||
|
model.setExtra(LoginHelper.CLIENT_ACCESS_PATH_KEY, client.getAccessPath());
|
||||||
|
model.setExtra(LoginHelper.CLIENT_IP_WHITELIST_KEY, client.getIpWhitelist());
|
||||||
|
if (ObjectUtil.isNotNull(customizer)) {
|
||||||
|
customizer.accept(model);
|
||||||
|
}
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 登录
|
* 登录
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -61,13 +61,7 @@ public class EmailAuthStrategy implements IAuthStrategy {
|
|||||||
LoginUser loginUser = loginService.buildLoginUser(user);
|
LoginUser loginUser = loginService.buildLoginUser(user);
|
||||||
loginUser.setClientKey(client.getClientKey());
|
loginUser.setClientKey(client.getClientKey());
|
||||||
loginUser.setDeviceType(client.getDeviceType());
|
loginUser.setDeviceType(client.getDeviceType());
|
||||||
SaLoginParameter model = new SaLoginParameter();
|
SaLoginParameter model = IAuthStrategy.buildLoginParameter(client);
|
||||||
model.setDeviceType(client.getDeviceType());
|
|
||||||
// 自定义分配 不同用户体系 不同 token 授权时间 不设置默认走全局 yml 配置
|
|
||||||
// 例如: 后台用户30分钟过期 app用户1天过期
|
|
||||||
model.setTimeout(client.getTimeout());
|
|
||||||
model.setActiveTimeout(client.getActiveTimeout());
|
|
||||||
model.setExtra(LoginHelper.CLIENT_KEY, client.getClientId());
|
|
||||||
// 生成token
|
// 生成token
|
||||||
LoginHelper.login(loginUser, model);
|
LoginHelper.login(loginUser, model);
|
||||||
|
|
||||||
|
|||||||
@@ -73,13 +73,7 @@ public class PasswordAuthStrategy implements IAuthStrategy {
|
|||||||
LoginUser loginUser = loginService.buildLoginUser(user);
|
LoginUser loginUser = loginService.buildLoginUser(user);
|
||||||
loginUser.setClientKey(client.getClientKey());
|
loginUser.setClientKey(client.getClientKey());
|
||||||
loginUser.setDeviceType(client.getDeviceType());
|
loginUser.setDeviceType(client.getDeviceType());
|
||||||
SaLoginParameter model = new SaLoginParameter();
|
SaLoginParameter model = IAuthStrategy.buildLoginParameter(client);
|
||||||
model.setDeviceType(client.getDeviceType());
|
|
||||||
// 自定义分配 不同用户体系 不同 token 授权时间 不设置默认走全局 yml 配置
|
|
||||||
// 例如: 后台用户30分钟过期 app用户1天过期
|
|
||||||
model.setTimeout(client.getTimeout());
|
|
||||||
model.setActiveTimeout(client.getActiveTimeout());
|
|
||||||
model.setExtra(LoginHelper.CLIENT_KEY, client.getClientId());
|
|
||||||
// 生成token
|
// 生成token
|
||||||
LoginHelper.login(loginUser, model);
|
LoginHelper.login(loginUser, model);
|
||||||
|
|
||||||
|
|||||||
@@ -61,13 +61,7 @@ public class SmsAuthStrategy implements IAuthStrategy {
|
|||||||
LoginUser loginUser = loginService.buildLoginUser(user);
|
LoginUser loginUser = loginService.buildLoginUser(user);
|
||||||
loginUser.setClientKey(client.getClientKey());
|
loginUser.setClientKey(client.getClientKey());
|
||||||
loginUser.setDeviceType(client.getDeviceType());
|
loginUser.setDeviceType(client.getDeviceType());
|
||||||
SaLoginParameter model = new SaLoginParameter();
|
SaLoginParameter model = IAuthStrategy.buildLoginParameter(client);
|
||||||
model.setDeviceType(client.getDeviceType());
|
|
||||||
// 自定义分配 不同用户体系 不同 token 授权时间 不设置默认走全局 yml 配置
|
|
||||||
// 例如: 后台用户30分钟过期 app用户1天过期
|
|
||||||
model.setTimeout(client.getTimeout());
|
|
||||||
model.setActiveTimeout(client.getActiveTimeout());
|
|
||||||
model.setExtra(LoginHelper.CLIENT_KEY, client.getClientId());
|
|
||||||
// 生成token
|
// 生成token
|
||||||
LoginHelper.login(loginUser, model);
|
LoginHelper.login(loginUser, model);
|
||||||
|
|
||||||
|
|||||||
@@ -73,13 +73,7 @@ public class SocialAuthStrategy implements IAuthStrategy {
|
|||||||
LoginUser loginUser = loginService.buildLoginUser(user);
|
LoginUser loginUser = loginService.buildLoginUser(user);
|
||||||
loginUser.setClientKey(client.getClientKey());
|
loginUser.setClientKey(client.getClientKey());
|
||||||
loginUser.setDeviceType(client.getDeviceType());
|
loginUser.setDeviceType(client.getDeviceType());
|
||||||
SaLoginParameter model = new SaLoginParameter();
|
SaLoginParameter model = IAuthStrategy.buildLoginParameter(client);
|
||||||
model.setDeviceType(client.getDeviceType());
|
|
||||||
// 自定义分配 不同用户体系 不同 token 授权时间 不设置默认走全局 yml 配置
|
|
||||||
// 例如: 后台用户30分钟过期 app用户1天过期
|
|
||||||
model.setTimeout(client.getTimeout());
|
|
||||||
model.setActiveTimeout(client.getActiveTimeout());
|
|
||||||
model.setExtra(LoginHelper.CLIENT_KEY, client.getClientId());
|
|
||||||
// 生成token
|
// 生成token
|
||||||
LoginHelper.login(loginUser, model);
|
LoginHelper.login(loginUser, model);
|
||||||
|
|
||||||
|
|||||||
@@ -82,13 +82,7 @@ public class XcxAuthStrategy implements IAuthStrategy {
|
|||||||
loginUser.setDeviceType(client.getDeviceType());
|
loginUser.setDeviceType(client.getDeviceType());
|
||||||
loginUser.setOpenid(openid);
|
loginUser.setOpenid(openid);
|
||||||
|
|
||||||
SaLoginParameter model = new SaLoginParameter();
|
SaLoginParameter model = IAuthStrategy.buildLoginParameter(client);
|
||||||
model.setDeviceType(client.getDeviceType());
|
|
||||||
// 自定义分配 不同用户体系 不同 token 授权时间 不设置默认走全局 yml 配置
|
|
||||||
// 例如: 后台用户30分钟过期 app用户1天过期
|
|
||||||
model.setTimeout(client.getTimeout());
|
|
||||||
model.setActiveTimeout(client.getActiveTimeout());
|
|
||||||
model.setExtra(LoginHelper.CLIENT_KEY, client.getClientId());
|
|
||||||
// 生成token
|
// 生成token
|
||||||
LoginHelper.login(loginUser, model);
|
LoginHelper.login(loginUser, model);
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import lombok.NoArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.dromara.common.core.utils.regex.RegexUtils;
|
import org.dromara.common.core.utils.regex.RegexUtils;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
import java.net.Inet6Address;
|
import java.net.Inet6Address;
|
||||||
import java.net.InetAddress;
|
import java.net.InetAddress;
|
||||||
import java.net.UnknownHostException;
|
import java.net.UnknownHostException;
|
||||||
@@ -52,7 +53,8 @@ public class NetUtils extends NetUtil {
|
|||||||
public static boolean isInnerIPv6(String ip) {
|
public static boolean isInnerIPv6(String ip) {
|
||||||
try {
|
try {
|
||||||
// 判断是否为IPv6地址
|
// 判断是否为IPv6地址
|
||||||
if (InetAddress.getByName(ip) instanceof Inet6Address inet6Address) {
|
InetAddress inetAddress = InetAddress.getByName(ip);
|
||||||
|
if (inetAddress instanceof Inet6Address inet6Address) {
|
||||||
// isAnyLocalAddress 判断是否为通配符地址,通常不会将其视为内网地址,根据业务场景自行处理判断
|
// isAnyLocalAddress 判断是否为通配符地址,通常不会将其视为内网地址,根据业务场景自行处理判断
|
||||||
// isLinkLocalAddress 判断是否为链路本地地址,通常不算内网地址,是否划分归属于内网需要根据业务场景自行处理判断
|
// isLinkLocalAddress 判断是否为链路本地地址,通常不算内网地址,是否划分归属于内网需要根据业务场景自行处理判断
|
||||||
// isLoopbackAddress 判断是否为环回地址,与IPv4的 127.0.0.1 同理,用于表示本机
|
// isLoopbackAddress 判断是否为环回地址,与IPv4的 127.0.0.1 同理,用于表示本机
|
||||||
@@ -81,4 +83,69 @@ public class NetUtils extends NetUtil {
|
|||||||
return RegexUtils.isMatch(PatternPool.IPV4, ip);
|
return RegexUtils.isMatch(PatternPool.IPV4, ip);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 匹配 IP 规则,支持精确值、通配符与 CIDR。
|
||||||
|
*
|
||||||
|
* @param rule IP 规则
|
||||||
|
* @param clientIp 客户端 IP
|
||||||
|
* @return 是否匹配
|
||||||
|
*/
|
||||||
|
public static boolean isMatchIpRule(String rule, String clientIp) {
|
||||||
|
if (StringUtils.isBlank(rule) || StringUtils.isBlank(clientIp)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String ipRule = StringUtils.trim(rule);
|
||||||
|
if (StringUtils.equals(ipRule, clientIp)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (ipRule.contains("/")) {
|
||||||
|
return isMatchCidr(ipRule, clientIp);
|
||||||
|
}
|
||||||
|
if (StringUtils.containsAny(ipRule, "*", "?")) {
|
||||||
|
String regex = ipRule
|
||||||
|
.replace(".", "\\.")
|
||||||
|
.replace("*", ".*")
|
||||||
|
.replace("?", ".");
|
||||||
|
return clientIp.matches(regex);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 匹配 CIDR 网段。
|
||||||
|
*
|
||||||
|
* @param cidr CIDR 规则
|
||||||
|
* @param clientIp 客户端 IP
|
||||||
|
* @return 是否命中
|
||||||
|
*/
|
||||||
|
public static boolean isMatchCidr(String cidr, String clientIp) {
|
||||||
|
try {
|
||||||
|
String[] parts = cidr.split("/");
|
||||||
|
if (parts.length != 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
InetAddress networkAddress = InetAddress.getByName(parts[0]);
|
||||||
|
InetAddress currentAddress = InetAddress.getByName(clientIp);
|
||||||
|
byte[] networkBytes = networkAddress.getAddress();
|
||||||
|
byte[] currentBytes = currentAddress.getAddress();
|
||||||
|
if (networkBytes.length != currentBytes.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int prefixLength = Integer.parseInt(parts[1]);
|
||||||
|
int maxPrefix = networkBytes.length * 8;
|
||||||
|
if (prefixLength < 0 || prefixLength > maxPrefix) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
BigInteger mask = prefixLength == 0
|
||||||
|
? BigInteger.ZERO
|
||||||
|
: BigInteger.ONE.shiftLeft(prefixLength).subtract(BigInteger.ONE).shiftLeft(maxPrefix - prefixLength);
|
||||||
|
BigInteger network = new BigInteger(1, networkBytes);
|
||||||
|
BigInteger current = new BigInteger(1, currentBytes);
|
||||||
|
return network.and(mask).equals(current.and(mask));
|
||||||
|
} catch (UnknownHostException | NumberFormatException e) {
|
||||||
|
log.debug("IP白名单CIDR规则解析失败: {}", cidr, e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ public class LoginHelper {
|
|||||||
public static final String DEPT_NAME_KEY = "deptName";
|
public static final String DEPT_NAME_KEY = "deptName";
|
||||||
public static final String DEPT_CATEGORY_KEY = "deptCategory";
|
public static final String DEPT_CATEGORY_KEY = "deptCategory";
|
||||||
public static final String CLIENT_KEY = "clientid";
|
public static final String CLIENT_KEY = "clientid";
|
||||||
|
public static final String CLIENT_ACCESS_PATH_KEY = "clientAccessPath";
|
||||||
|
public static final String CLIENT_IP_WHITELIST_KEY = "clientIpWhitelist";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 登录系统 基于 设备类型
|
* 登录系统 基于 设备类型
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.dromara.common.security.config;
|
package org.dromara.common.security.config;
|
||||||
|
|
||||||
import cn.dev33.satoken.exception.NotLoginException;
|
import cn.dev33.satoken.exception.NotLoginException;
|
||||||
|
import cn.dev33.satoken.exception.NotPermissionException;
|
||||||
import cn.dev33.satoken.filter.SaServletFilter;
|
import cn.dev33.satoken.filter.SaServletFilter;
|
||||||
import cn.dev33.satoken.httpauth.basic.SaHttpBasicUtil;
|
import cn.dev33.satoken.httpauth.basic.SaHttpBasicUtil;
|
||||||
import cn.dev33.satoken.interceptor.SaInterceptor;
|
import cn.dev33.satoken.interceptor.SaInterceptor;
|
||||||
@@ -13,6 +14,7 @@ import jakarta.servlet.http.HttpServletResponse;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.dromara.common.core.constant.HttpStatus;
|
import org.dromara.common.core.constant.HttpStatus;
|
||||||
|
import org.dromara.common.core.utils.NetUtils;
|
||||||
import org.dromara.common.core.utils.ServletUtils;
|
import org.dromara.common.core.utils.ServletUtils;
|
||||||
import org.dromara.common.core.utils.SpringUtils;
|
import org.dromara.common.core.utils.SpringUtils;
|
||||||
import org.dromara.common.core.utils.StringUtils;
|
import org.dromara.common.core.utils.StringUtils;
|
||||||
@@ -26,6 +28,8 @@ import org.springframework.context.annotation.Bean;
|
|||||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 权限安全配置
|
* 权限安全配置
|
||||||
*
|
*
|
||||||
@@ -38,6 +42,8 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class SecurityConfig implements WebMvcConfigurer {
|
public class SecurityConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
|
private static final String CLIENT_RULE_SEPARATOR_REGEX = "[,;\\r\\n]+";
|
||||||
|
|
||||||
private final SecurityProperties securityProperties;
|
private final SecurityProperties securityProperties;
|
||||||
@Value("${message.path:/resource/message}")
|
@Value("${message.path:/resource/message}")
|
||||||
private String messagePath;
|
private String messagePath;
|
||||||
@@ -74,6 +80,7 @@ public class SecurityConfig implements WebMvcConfigurer {
|
|||||||
"-100", "客户端ID与Token不匹配",
|
"-100", "客户端ID与Token不匹配",
|
||||||
StpUtil.getTokenValue());
|
StpUtil.getTokenValue());
|
||||||
}
|
}
|
||||||
|
validateClientAccessRules(request);
|
||||||
|
|
||||||
// 有效率影响 用于临时测试
|
// 有效率影响 用于临时测试
|
||||||
// if (log.isDebugEnabled()) {
|
// if (log.isDebugEnabled()) {
|
||||||
@@ -109,4 +116,41 @@ public class SecurityConfig implements WebMvcConfigurer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按客户端配置校验接口访问路径与来源 IP。
|
||||||
|
*
|
||||||
|
* @param request 当前请求
|
||||||
|
*/
|
||||||
|
private void validateClientAccessRules(HttpServletRequest request) {
|
||||||
|
String requestPath = StringUtils.blankToDefault(request.getServletPath(), request.getRequestURI());
|
||||||
|
String accessPath = getTokenExtra(LoginHelper.CLIENT_ACCESS_PATH_KEY);
|
||||||
|
if (StringUtils.isNotBlank(accessPath)) {
|
||||||
|
List<String> accessPathList = StringUtils.str2List(accessPath, CLIENT_RULE_SEPARATOR_REGEX, true, true);
|
||||||
|
if (!StringUtils.matches(requestPath, accessPathList)) {
|
||||||
|
throw new NotPermissionException("当前客户端未授权访问该接口路径");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String ipWhitelist = getTokenExtra(LoginHelper.CLIENT_IP_WHITELIST_KEY);
|
||||||
|
if (StringUtils.isNotBlank(ipWhitelist)) {
|
||||||
|
String clientIp = ServletUtils.getClientIP(request);
|
||||||
|
List<String> ipWhitelistList = StringUtils.str2List(ipWhitelist, CLIENT_RULE_SEPARATOR_REGEX, true, true);
|
||||||
|
boolean matched = ipWhitelistList.stream().anyMatch(rule -> NetUtils.isMatchIpRule(rule, clientIp));
|
||||||
|
if (!matched) {
|
||||||
|
throw new NotPermissionException("当前客户端IP不在白名单内");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取 token 扩展信息,兼容空值场景。
|
||||||
|
*
|
||||||
|
* @param key 扩展字段
|
||||||
|
* @return 扩展值
|
||||||
|
*/
|
||||||
|
private String getTokenExtra(String key) {
|
||||||
|
Object extra = StpUtil.getExtra(key);
|
||||||
|
return extra == null ? null : extra.toString();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,16 @@ public class SysClient extends BaseEntity {
|
|||||||
*/
|
*/
|
||||||
private String deviceType;
|
private String deviceType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 允许访问路径
|
||||||
|
*/
|
||||||
|
private String accessPath;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IP白名单
|
||||||
|
*/
|
||||||
|
private String ipWhitelist;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* token活跃超时时间
|
* token活跃超时时间
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -64,6 +64,26 @@ public class SysClientBo implements Serializable {
|
|||||||
*/
|
*/
|
||||||
private String deviceType;
|
private String deviceType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 允许访问路径
|
||||||
|
*/
|
||||||
|
private String accessPath;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 允许访问路径列表
|
||||||
|
*/
|
||||||
|
private List<String> accessPathList;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IP白名单
|
||||||
|
*/
|
||||||
|
private String ipWhitelist;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IP白名单列表
|
||||||
|
*/
|
||||||
|
private List<String> ipWhitelistList;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* token活跃超时时间
|
* token活跃超时时间
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -67,6 +67,28 @@ public class SysClientVo implements Serializable {
|
|||||||
*/
|
*/
|
||||||
private String deviceType;
|
private String deviceType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 允许访问路径
|
||||||
|
*/
|
||||||
|
@ExcelProperty(value = "允许访问路径")
|
||||||
|
private String accessPath;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 允许访问路径列表
|
||||||
|
*/
|
||||||
|
private List<String> accessPathList;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IP白名单
|
||||||
|
*/
|
||||||
|
@ExcelProperty(value = "IP白名单")
|
||||||
|
private String ipWhitelist;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IP白名单列表
|
||||||
|
*/
|
||||||
|
private List<String> ipWhitelistList;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* token活跃超时时间
|
* token活跃超时时间
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import org.springframework.stereotype.Service;
|
|||||||
|
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.function.UnaryOperator;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 客户端管理Service业务层处理
|
* 客户端管理Service业务层处理
|
||||||
@@ -37,6 +38,8 @@ import java.util.List;
|
|||||||
@Service
|
@Service
|
||||||
public class SysClientServiceImpl implements ISysClientService {
|
public class SysClientServiceImpl implements ISysClientService {
|
||||||
|
|
||||||
|
private static final String CLIENT_RULE_SEPARATOR_REGEX = "[,;\\r\\n]+";
|
||||||
|
|
||||||
private final SysClientMapper baseMapper;
|
private final SysClientMapper baseMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,7 +51,7 @@ public class SysClientServiceImpl implements ISysClientService {
|
|||||||
@Override
|
@Override
|
||||||
public SysClientVo queryById(Long id) {
|
public SysClientVo queryById(Long id) {
|
||||||
SysClientVo vo = baseMapper.selectVoById(id);
|
SysClientVo vo = baseMapper.selectVoById(id);
|
||||||
vo.setGrantTypeList(StringUtils.splitList(vo.getGrantType()));
|
fillClientRuleFields(vo);
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +64,9 @@ public class SysClientServiceImpl implements ISysClientService {
|
|||||||
@Cacheable(cacheNames = CacheNames.SYS_CLIENT, key = "#clientId")
|
@Cacheable(cacheNames = CacheNames.SYS_CLIENT, key = "#clientId")
|
||||||
@Override
|
@Override
|
||||||
public SysClientVo queryByClientId(String clientId) {
|
public SysClientVo queryByClientId(String clientId) {
|
||||||
return baseMapper.selectVoOne(new LambdaQueryWrapper<SysClient>().eq(SysClient::getClientId, clientId));
|
SysClientVo vo = baseMapper.selectVoOne(new LambdaQueryWrapper<SysClient>().eq(SysClient::getClientId, clientId));
|
||||||
|
fillClientRuleFields(vo);
|
||||||
|
return vo;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -75,7 +80,7 @@ public class SysClientServiceImpl implements ISysClientService {
|
|||||||
public PageResult<SysClientVo> queryPageList(SysClientBo bo, PageQuery pageQuery) {
|
public PageResult<SysClientVo> queryPageList(SysClientBo bo, PageQuery pageQuery) {
|
||||||
LambdaQueryWrapper<SysClient> lqw = buildQueryWrapper(bo);
|
LambdaQueryWrapper<SysClient> lqw = buildQueryWrapper(bo);
|
||||||
Page<SysClientVo> result = baseMapper.selectVoPage(pageQuery.build(), lqw);
|
Page<SysClientVo> result = baseMapper.selectVoPage(pageQuery.build(), lqw);
|
||||||
result.getRecords().forEach(r -> r.setGrantTypeList(StringUtils.splitList(r.getGrantType())));
|
result.getRecords().forEach(this::fillClientRuleFields);
|
||||||
return PageResult.build(result.getRecords(), result.getTotal());
|
return PageResult.build(result.getRecords(), result.getTotal());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +93,9 @@ public class SysClientServiceImpl implements ISysClientService {
|
|||||||
@Override
|
@Override
|
||||||
public List<SysClientVo> queryList(SysClientBo bo) {
|
public List<SysClientVo> queryList(SysClientBo bo) {
|
||||||
LambdaQueryWrapper<SysClient> lqw = buildQueryWrapper(bo);
|
LambdaQueryWrapper<SysClient> lqw = buildQueryWrapper(bo);
|
||||||
return baseMapper.selectVoList(lqw);
|
List<SysClientVo> list = baseMapper.selectVoList(lqw);
|
||||||
|
list.forEach(this::fillClientRuleFields);
|
||||||
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -117,6 +124,8 @@ public class SysClientServiceImpl implements ISysClientService {
|
|||||||
public Boolean insertByBo(SysClientBo bo) {
|
public Boolean insertByBo(SysClientBo bo) {
|
||||||
SysClient add = MapstructUtils.convert(bo, SysClient.class);
|
SysClient add = MapstructUtils.convert(bo, SysClient.class);
|
||||||
add.setGrantType(CollUtil.join(bo.getGrantTypeList(), StringUtils.SEPARATOR));
|
add.setGrantType(CollUtil.join(bo.getGrantTypeList(), StringUtils.SEPARATOR));
|
||||||
|
add.setAccessPath(resolveRuleValue(bo.getAccessPath(), bo.getAccessPathList(), this::normalizeAccessPath));
|
||||||
|
add.setIpWhitelist(resolveRuleValue(bo.getIpWhitelist(), bo.getIpWhitelistList(), UnaryOperator.identity()));
|
||||||
// 生成clientid
|
// 生成clientid
|
||||||
String clientKey = bo.getClientKey();
|
String clientKey = bo.getClientKey();
|
||||||
String clientSecret = bo.getClientSecret();
|
String clientSecret = bo.getClientSecret();
|
||||||
@@ -139,6 +148,8 @@ public class SysClientServiceImpl implements ISysClientService {
|
|||||||
public Boolean updateByBo(SysClientBo bo) {
|
public Boolean updateByBo(SysClientBo bo) {
|
||||||
SysClient update = MapstructUtils.convert(bo, SysClient.class);
|
SysClient update = MapstructUtils.convert(bo, SysClient.class);
|
||||||
update.setGrantType(StringUtils.joinComma(bo.getGrantTypeList()));
|
update.setGrantType(StringUtils.joinComma(bo.getGrantTypeList()));
|
||||||
|
update.setAccessPath(resolveRuleValue(bo.getAccessPath(), bo.getAccessPathList(), this::normalizeAccessPath));
|
||||||
|
update.setIpWhitelist(resolveRuleValue(bo.getIpWhitelist(), bo.getIpWhitelistList(), UnaryOperator.identity()));
|
||||||
return baseMapper.updateById(update) > 0;
|
return baseMapper.updateById(update) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,4 +196,73 @@ public class SysClientServiceImpl implements ISysClientService {
|
|||||||
return !exist;
|
return !exist;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回填客户端扩展规则字段,便于前端直接展示和编辑。
|
||||||
|
*
|
||||||
|
* @param vo 客户端视图对象
|
||||||
|
*/
|
||||||
|
private void fillClientRuleFields(SysClientVo vo) {
|
||||||
|
if (ObjectUtil.isNull(vo)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
vo.setGrantTypeList(StringUtils.splitList(vo.getGrantType()));
|
||||||
|
vo.setAccessPathList(parseRuleList(vo.getAccessPath(), this::normalizeAccessPath));
|
||||||
|
vo.setIpWhitelistList(parseRuleList(vo.getIpWhitelist(), UnaryOperator.identity()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一处理白名单与路径规则的入库格式。
|
||||||
|
*
|
||||||
|
* @param rawValue 原始字符串
|
||||||
|
* @param listValue 列表值
|
||||||
|
* @param normalizer 单条规则归一化器
|
||||||
|
* @return 逗号拼接后的规则串
|
||||||
|
*/
|
||||||
|
private String resolveRuleValue(String rawValue, List<String> listValue, UnaryOperator<String> normalizer) {
|
||||||
|
List<String> rules = CollUtil.isNotEmpty(listValue)
|
||||||
|
? listValue
|
||||||
|
: StringUtils.str2List(rawValue, CLIENT_RULE_SEPARATOR_REGEX, true, true);
|
||||||
|
if (CollUtil.isEmpty(rules)) {
|
||||||
|
return listValue != null || rawValue != null ? "" : null;
|
||||||
|
}
|
||||||
|
return CollUtil.join(rules.stream()
|
||||||
|
.map(normalizer)
|
||||||
|
.filter(StringUtils::isNotBlank)
|
||||||
|
.toList(), StringUtils.SEPARATOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将规则串转换为列表。
|
||||||
|
*
|
||||||
|
* @param value 规则串
|
||||||
|
* @param normalizer 单条规则归一化器
|
||||||
|
* @return 规则列表
|
||||||
|
*/
|
||||||
|
private List<String> parseRuleList(String value, UnaryOperator<String> normalizer) {
|
||||||
|
return StringUtils.str2List(value, CLIENT_RULE_SEPARATOR_REGEX, true, true).stream()
|
||||||
|
.map(normalizer)
|
||||||
|
.filter(StringUtils::isNotBlank)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一补齐路径前导斜杠,避免配置成 app/** 时无法命中。
|
||||||
|
*
|
||||||
|
* @param path 路径规则
|
||||||
|
* @return 规范化后的路径规则
|
||||||
|
*/
|
||||||
|
private String normalizeAccessPath(String path) {
|
||||||
|
if (StringUtils.isBlank(path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String accessPath = StringUtils.trim(path);
|
||||||
|
if (StringUtils.isBlank(accessPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (StringUtils.equals(accessPath, "*") || StringUtils.equals(accessPath, "/**")) {
|
||||||
|
return "/**";
|
||||||
|
}
|
||||||
|
return accessPath.startsWith(StringUtils.SLASH) ? accessPath : StringUtils.SLASH + accessPath;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1211,6 +1211,8 @@ create table sys_client (
|
|||||||
client_secret varchar2(255) default null,
|
client_secret varchar2(255) default null,
|
||||||
grant_type varchar2(255) default null,
|
grant_type varchar2(255) default null,
|
||||||
device_type varchar2(32) default null,
|
device_type varchar2(32) default null,
|
||||||
|
access_path varchar2(2000) default null,
|
||||||
|
ip_whitelist varchar2(1000) default null,
|
||||||
active_timeout number(11) default 1800,
|
active_timeout number(11) default 1800,
|
||||||
timeout number(11) default 604800,
|
timeout number(11) default 604800,
|
||||||
status char(1) default '0',
|
status char(1) default '0',
|
||||||
@@ -1231,6 +1233,8 @@ comment on column sys_client.client_key is '客户端key';
|
|||||||
comment on column sys_client.client_secret is '客户端秘钥';
|
comment on column sys_client.client_secret is '客户端秘钥';
|
||||||
comment on column sys_client.grant_type is '授权类型';
|
comment on column sys_client.grant_type is '授权类型';
|
||||||
comment on column sys_client.device_type is '设备类型';
|
comment on column sys_client.device_type is '设备类型';
|
||||||
|
comment on column sys_client.access_path is '允许访问路径';
|
||||||
|
comment on column sys_client.ip_whitelist is 'IP白名单';
|
||||||
comment on column sys_client.active_timeout is 'token活跃超时时间';
|
comment on column sys_client.active_timeout is 'token活跃超时时间';
|
||||||
comment on column sys_client.timeout is 'token固定超时';
|
comment on column sys_client.timeout is 'token固定超时';
|
||||||
comment on column sys_client.status is '状态(0正常 1停用)';
|
comment on column sys_client.status is '状态(0正常 1停用)';
|
||||||
@@ -1241,8 +1245,8 @@ comment on column sys_client.create_time is '创建时间';
|
|||||||
comment on column sys_client.update_by is '更新者';
|
comment on column sys_client.update_by is '更新者';
|
||||||
comment on column sys_client.update_time is '更新时间';
|
comment on column sys_client.update_time is '更新时间';
|
||||||
|
|
||||||
insert into sys_client values (1762000000000000001, 'e5cd7e4891bf95d1d19206ce24a7b32e', 'pc', 'pc123', 'password,social', 'pc', 1800, 604800, 0, 0, 1761000000000000103, 1761100000000000001, sysdate, 1761100000000000001, sysdate);
|
insert into sys_client values (1762000000000000001, 'e5cd7e4891bf95d1d19206ce24a7b32e', 'pc', 'pc123', 'password,social', 'pc', null, null, 1800, 604800, 0, 0, 1761000000000000103, 1761100000000000001, sysdate, 1761100000000000001, sysdate);
|
||||||
insert into sys_client values (1762000000000000002, '428a8310cd442757ae699df5d894f051', 'app', 'app123', 'password,sms,social', 'android', 1800, 604800, 0, 0, 1761000000000000103, 1761100000000000001, sysdate, 1761100000000000001, sysdate);
|
insert into sys_client values (1762000000000000002, '428a8310cd442757ae699df5d894f051', 'app', 'app123', 'password,sms,social', 'android', '/app/**', null, 1800, 604800, 0, 0, 1761000000000000103, 1761100000000000001, sysdate, 1761100000000000001, sysdate);
|
||||||
|
|
||||||
create table test_demo (
|
create table test_demo (
|
||||||
id number(20) not null,
|
id number(20) not null,
|
||||||
|
|||||||
@@ -1206,6 +1206,8 @@ create table sys_client (
|
|||||||
client_secret varchar(255) default ''::varchar,
|
client_secret varchar(255) default ''::varchar,
|
||||||
grant_type varchar(255) default ''::varchar,
|
grant_type varchar(255) default ''::varchar,
|
||||||
device_type varchar(32) default ''::varchar,
|
device_type varchar(32) default ''::varchar,
|
||||||
|
access_path varchar(2000) default ''::varchar,
|
||||||
|
ip_whitelist varchar(1000) default ''::varchar,
|
||||||
active_timeout int4 default 1800,
|
active_timeout int4 default 1800,
|
||||||
timeout int4 default 604800,
|
timeout int4 default 604800,
|
||||||
status char(1) default '0'::bpchar,
|
status char(1) default '0'::bpchar,
|
||||||
@@ -1225,6 +1227,8 @@ comment on column sys_client.client_key is '客户端key';
|
|||||||
comment on column sys_client.client_secret is '客户端秘钥';
|
comment on column sys_client.client_secret is '客户端秘钥';
|
||||||
comment on column sys_client.grant_type is '授权类型';
|
comment on column sys_client.grant_type is '授权类型';
|
||||||
comment on column sys_client.device_type is '设备类型';
|
comment on column sys_client.device_type is '设备类型';
|
||||||
|
comment on column sys_client.access_path is '允许访问路径';
|
||||||
|
comment on column sys_client.ip_whitelist is 'IP白名单';
|
||||||
comment on column sys_client.active_timeout is 'token活跃超时时间';
|
comment on column sys_client.active_timeout is 'token活跃超时时间';
|
||||||
comment on column sys_client.timeout is 'token固定超时';
|
comment on column sys_client.timeout is 'token固定超时';
|
||||||
comment on column sys_client.status is '状态(0正常 1停用)';
|
comment on column sys_client.status is '状态(0正常 1停用)';
|
||||||
@@ -1235,8 +1239,8 @@ comment on column sys_client.create_time is '创建时间';
|
|||||||
comment on column sys_client.update_by is '更新者';
|
comment on column sys_client.update_by is '更新者';
|
||||||
comment on column sys_client.update_time is '更新时间';
|
comment on column sys_client.update_time is '更新时间';
|
||||||
|
|
||||||
insert into sys_client values (1762000000000000001, 'e5cd7e4891bf95d1d19206ce24a7b32e', 'pc', 'pc123', 'password,social', 'pc', 1800, 604800, 0, 0, 1761000000000000103, 1761100000000000001, now(), 1761100000000000001, now());
|
insert into sys_client values (1762000000000000001, 'e5cd7e4891bf95d1d19206ce24a7b32e', 'pc', 'pc123', 'password,social', 'pc', '', '', 1800, 604800, 0, 0, 1761000000000000103, 1761100000000000001, now(), 1761100000000000001, now());
|
||||||
insert into sys_client values (1762000000000000002, '428a8310cd442757ae699df5d894f051', 'app', 'app123', 'password,sms,social', 'android', 1800, 604800, 0, 0, 1761000000000000103, 1761100000000000001, now(), 1761100000000000001, now());
|
insert into sys_client values (1762000000000000002, '428a8310cd442757ae699df5d894f051', 'app', 'app123', 'password,sms,social', 'android', '/app/**', '', 1800, 604800, 0, 0, 1761000000000000103, 1761100000000000001, now(), 1761100000000000001, now());
|
||||||
|
|
||||||
create table if not exists test_demo
|
create table if not exists test_demo
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -852,6 +852,8 @@ create table sys_client (
|
|||||||
client_secret varchar(255) default null comment '客户端秘钥',
|
client_secret varchar(255) default null comment '客户端秘钥',
|
||||||
grant_type varchar(255) default null comment '授权类型',
|
grant_type varchar(255) default null comment '授权类型',
|
||||||
device_type varchar(32) default null comment '设备类型',
|
device_type varchar(32) default null comment '设备类型',
|
||||||
|
access_path varchar(2000) default null comment '允许访问路径',
|
||||||
|
ip_whitelist varchar(1000) default null comment 'IP白名单',
|
||||||
active_timeout int(11) default 1800 comment 'token活跃超时时间',
|
active_timeout int(11) default 1800 comment 'token活跃超时时间',
|
||||||
timeout int(11) default 604800 comment 'token固定超时',
|
timeout int(11) default 604800 comment 'token固定超时',
|
||||||
status char(1) default '0' comment '状态(0正常 1停用)',
|
status char(1) default '0' comment '状态(0正常 1停用)',
|
||||||
@@ -864,8 +866,8 @@ create table sys_client (
|
|||||||
primary key (id)
|
primary key (id)
|
||||||
) engine=innodb comment='系统授权表';
|
) engine=innodb comment='系统授权表';
|
||||||
|
|
||||||
insert into sys_client values (1762000000000000001, 'e5cd7e4891bf95d1d19206ce24a7b32e', 'pc', 'pc123', 'password,social', 'pc', 1800, 604800, 0, 0, 1761000000000000103, 1761100000000000001, sysdate(), 1761100000000000001, sysdate());
|
insert into sys_client values (1762000000000000001, 'e5cd7e4891bf95d1d19206ce24a7b32e', 'pc', 'pc123', 'password,social', 'pc', null, null, 1800, 604800, 0, 0, 1761000000000000103, 1761100000000000001, sysdate(), 1761100000000000001, sysdate());
|
||||||
insert into sys_client values (1762000000000000002, '428a8310cd442757ae699df5d894f051', 'app', 'app123', 'password,sms,social', 'android', 1800, 604800, 0, 0, 1761000000000000103, 1761100000000000001, sysdate(), 1761100000000000001, sysdate());
|
insert into sys_client values (1762000000000000002, '428a8310cd442757ae699df5d894f051', 'app', 'app123', 'password,sms,social', 'android', '/app/**', null, 1800, 604800, 0, 0, 1761000000000000103, 1761100000000000001, sysdate(), 1761100000000000001, sysdate());
|
||||||
|
|
||||||
|
|
||||||
CREATE TABLE test_demo
|
CREATE TABLE test_demo
|
||||||
|
|||||||
@@ -3060,6 +3060,8 @@ CREATE TABLE sys_client
|
|||||||
client_secret nvarchar(255) DEFAULT '' NULL,
|
client_secret nvarchar(255) DEFAULT '' NULL,
|
||||||
grant_type nvarchar(255) DEFAULT '' NULL,
|
grant_type nvarchar(255) DEFAULT '' NULL,
|
||||||
device_type nvarchar(32) DEFAULT '' NULL,
|
device_type nvarchar(32) DEFAULT '' NULL,
|
||||||
|
access_path nvarchar(2000) DEFAULT '' NULL,
|
||||||
|
ip_whitelist nvarchar(1000) DEFAULT '' NULL,
|
||||||
active_timeout int DEFAULT ((1800)) NULL,
|
active_timeout int DEFAULT ((1800)) NULL,
|
||||||
timeout int DEFAULT ((604800)) NULL,
|
timeout int DEFAULT ((604800)) NULL,
|
||||||
status nchar(1) DEFAULT ('0') NULL,
|
status nchar(1) DEFAULT ('0') NULL,
|
||||||
@@ -3112,6 +3114,18 @@ EXEC sp_addextendedproperty
|
|||||||
'TABLE', N'sys_client',
|
'TABLE', N'sys_client',
|
||||||
'COLUMN', N'device_type'
|
'COLUMN', N'device_type'
|
||||||
GO
|
GO
|
||||||
|
EXEC sp_addextendedproperty
|
||||||
|
'MS_Description', N'允许访问路径',
|
||||||
|
'SCHEMA', N'dbo',
|
||||||
|
'TABLE', N'sys_client',
|
||||||
|
'COLUMN', N'access_path'
|
||||||
|
GO
|
||||||
|
EXEC sp_addextendedproperty
|
||||||
|
'MS_Description', N'IP白名单',
|
||||||
|
'SCHEMA', N'dbo',
|
||||||
|
'TABLE', N'sys_client',
|
||||||
|
'COLUMN', N'ip_whitelist'
|
||||||
|
GO
|
||||||
EXEC sp_addextendedproperty
|
EXEC sp_addextendedproperty
|
||||||
'MS_Description', N'token活跃超时时间',
|
'MS_Description', N'token活跃超时时间',
|
||||||
'SCHEMA', N'dbo',
|
'SCHEMA', N'dbo',
|
||||||
@@ -3172,9 +3186,9 @@ EXEC sp_addextendedproperty
|
|||||||
'TABLE', N'sys_client'
|
'TABLE', N'sys_client'
|
||||||
GO
|
GO
|
||||||
|
|
||||||
INSERT INTO sys_client VALUES (1762000000000000001, N'e5cd7e4891bf95d1d19206ce24a7b32e', N'pc', N'pc123', N'password,social', N'pc', 1800, 604800, N'0', N'0', 1761000000000000103, 1761100000000000001, getdate(), 1761100000000000001, getdate());
|
INSERT INTO sys_client VALUES (1762000000000000001, N'e5cd7e4891bf95d1d19206ce24a7b32e', N'pc', N'pc123', N'password,social', N'pc', N'', N'', 1800, 604800, N'0', N'0', 1761000000000000103, 1761100000000000001, getdate(), 1761100000000000001, getdate());
|
||||||
GO
|
GO
|
||||||
INSERT INTO sys_client VALUES (1762000000000000002, N'428a8310cd442757ae699df5d894f051', N'app', N'app123', N'password,sms,social', N'android', 1800, 604800, N'0', N'0', 1761000000000000103, 1761100000000000001, getdate(), 1761100000000000001, getdate());
|
INSERT INTO sys_client VALUES (1762000000000000002, N'428a8310cd442757ae699df5d894f051', N'app', N'app123', N'password,sms,social', N'android', N'/app/**', N'', 1800, 604800, N'0', N'0', 1761000000000000103, 1761100000000000001, getdate(), 1761100000000000001, getdate());
|
||||||
GO
|
GO
|
||||||
|
|
||||||
CREATE TABLE test_demo
|
CREATE TABLE test_demo
|
||||||
|
|||||||
Reference in New Issue
Block a user