场景
有一个2018年的老项目,没有使用spring security和oauth2,现在有一个需求-“实现与crm系统数据的同步”。项目并没有针对第三方系统的安全鉴权,一切从零开始。
根据项目的登录接口查看有关 token 的生成和校验,摸清楚项目登录的 token 是根据随机数+用户hash值得到的,token相关信息保存在redis,由项目的拦截器实现对 token 的校验,并将用户基础信息保存到上下文中。
oauth2的client
oauth2有四种鉴权模式,密码模式,隐藏式,客户端模式,授权码模式,而客户端模式就符合系统之间的对接。
oauth2有两个关键的基础配置,一个是用户配置,另一个是客户端配置,而客户端模式主要使用到客户端配置,所以老项目可以创建自己的客户端配置,实现客户端模式。
老项目改造
目标
1,模仿oauth2给老项目加客户端模式,但是不能影响原来的登录和鉴权。
2,客户端模式支持数据持久化和新增
3,客户端模式的接口与普通用户的接口进行隔离
表设计
CREATE TABLE `client` (
`id` bigint(20) NOT NULL,
`app_id` varchar(255) NOT NULL COMMENT '账号',
`app_secret` varchar(255) NOT NULL COMMENT '秘钥',
`create_time` datetime DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`create_user_id` varchar(255) DEFAULT NULL,
`update_user_id` varchar(255) DEFAULT NULL,
`is_delete` tinyint(2) DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户端配置';
表相关的mybatis-plus配置
实体类
@Getter
@Setter
@Accessors(chain = true)
@TableName("client")
public class Client implements Serializable {
private static final long serialVersionUID = 1L;
@TableId("id")
private Long id;
/**
* 账号
*/
@TableField("app_id")
private String appId;
/**
* 秘钥
*/
@TableField("app_secret")
private String appSecret;
@TableField("create_time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createTime;
@TableField("update_time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date updateTime;
@TableField("create_user_id")
private String createUserId;
@TableField("update_user_id")
private String updateUserId;
@TableField("is_delete")
private Integer isDelete;
}
mapper接口
public interface ClientMapper extends BaseMapper<Client> {
}
xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zhang.product.plus.system.ClientMapper">
</mapper>
api
@PermissionMapping是原来项目的白名单注解,这里开放获取token的接口
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/systemClient")
public class ClientController {
private final IClientService clientService;
/**
* 客户端获取token
*
* @param in 客户端信息
* @return token
* @author zfj
* @date 2024/7/31
*/
@PermissionMapping(name = "客户端获取token", loginIntercept = false, isIntercept = false)
@PostMapping("/getToken")
public Output<String> getToken(@Valid @RequestBody ClientGetTokenReqDTO in) {
return Output.success(clientService.getToken(in));
}
/**
* 新增客户端
*
* @param in 客户端信息
* @return 客户信息
* @author zfj
* @date 2024/7/31
*/
@PostMapping("/addClient")
public Output<PClient> addClient(@Valid @RequestBody ClientGetTokenReqDTO in) {
return Output.success(clientService.addClient(in));
}
}
接口
public interface IClientService {
/**
* 客户端获取token
*
* @param in 客户端信息
* @return token
* @author zfj
* @date 2024/7/31
*/
String getToken(ClientGetTokenReqDTO in);
/**
* 新增客户端
*
* @param in 客户端信息
* @return 客户信息
* @author zfj
* @date 2024/7/31
*/
PClient addClient(ClientGetTokenReqDTO in);
}
实现类
- 这里addClient用于开发环境给客户端新增配置,并不开放出(由于数量少,无需做页面配置),所以并不做具体appId的校验。
- 密码使用密文保存
- authManager是项目原来的token管理
@Slf4j
@RequiredArgsConstructor
@Service
public class ClientServiceImpl extends ServiceImpl<ClientMapper, PClient> implements IClientService {
private final AuthManager authManager;
private final PasswordEncoder passwordEncoder;
@Override
public String getToken(ClientGetTokenReqDTO in) {
PClient one = new LambdaQueryChainWrapper<>(this.getBaseMapper())
.eq(PClient::getAppId,in.getAppId())
.eq(PClient::getIsDelete, YesOrNoEnum.NO.getCode())
.one();
if(Objects.isNull(one)){
KingHoodExceptionUtil.throwException("appId不正确");
}
boolean matches = passwordEncoder.matches(in.getAppSecret(), one.getAppSecret());
if (!matches){
KingHoodExceptionUtil.throwException("appSecret不正确");
}
CurrentUserExtInfo info = new CurrentUserExtInfo();
info.setClient(true);
info.setClientInfo(one);
return authManager.getToken(in.getAppId(), JSON.toJSONString(info));
}
@Override
public PClient addClient(ClientGetTokenReqDTO in) {
PClient client = new PClient();
client.setAppId(in.getAppId());
client.setAppSecret(passwordEncoder.encode(in.getAppSecret()));
client.setId(IdGenUtil.getId());
client.setCreateTime(new Date());
client.setCreateUserId(SystemUserUtil.getCurrentUser().getId());
this.save(client);
return client;
}
}
authManager的getToken方法
- createAccessToken方法自定义一个token生成规则即可,比如uuid+id,然后做MD5摘要
/**
* 获取token并刷新缓存
* */
public String getToken(String id, String info) {
String token = redisClusterHelper.get(APP + id);
if (StringUtils.isEmpty(token)) {
token = createAccessToken(id);
}
int expire = 3600 * 24;
redisClusterHelper.set(APP + id, token, expire);
redisClusterHelper.set(APP + token, info, expire);
return token;
}
CurrentUserExtInfo 是上下文保存的数据,原来是采用json字符串作为value存储
在原来的上下文配置中新增了客户端的属性
/**
* 是否客户端
* */
private boolean client;
/**
* 客户端信息
* */
private PClient clientInfo;
拦截器兼容
拦截器主要做两件事,一个是对token进行校验,另一个是封装上下文,所以兼容处理做到以下几点
1,token校验兼容
2,上下文兼容
3,新增开放接口的识别
token校验兼容
- 原来的token校验走 authManager的 isAuth方法
原来的代码是
public boolean isAuth(String token) {
boolean auth = true;
String json = redisClusterHelper.get(user_+token);
if(StringUtils.isEmpty(json)) return false;
return auth;
}
修改后的代码,用户登录缓存的key采用常量user_作为前缀,客户端采用常量APP作为前缀
代码大体上没有变动,新增了json = redisClusterHelper.get(APP+token);
public boolean isAuth(String token) {
String json = redisClusterHelper.get(user_+token);
if(StringUtils.isEmpty(json)){
json = redisClusterHelper.get(APP+token);
if(Strings.isNullOrEmpty(json)){
return false;
}
}
return true;
}
上下文保存兼容
- 上下文的保存关键在于从缓存中获取到token的相关数据
原来的代码
public CurrentUserExtInfo getUserInfo(String token) {
log.info("获取基础用户信息{}",token);
if(StringUtils.isBlank(token)){
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes())
.getRequest();
token = request.getHeader("Authorization");
if(!AssertValue.isEmpty(token)){
token = token.replace("Bearer ","");
}
}
if(StringUtils.isEmpty(token)) throw new MessageException(OutputCode.TX.getCode(), OutputCode.TX.getMessage());
String json = redisClusterHelper.get(user_+token);
if(StringUtils.isEmpty(json)) throw new MessageException(OutputCode.TX.getCode(), OutputCode.TX.getMessage());
return JSON.parseObject(json,CurrentUserExtInfo.class);
}
修改后的代码,大体上没有变,新增了 json = redisClusterHelper.get(APP+token);
public CurrentUserExtInfo getUserInfo(String token) {
log.info("获取基础token信息{}",token);
if(StringUtils.isBlank(token)){
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes())
.getRequest();
token = request.getHeader("Authorization");
if(!AssertValue.isEmpty(token)){
token = token.replace("Bearer ","");
}
}
if(StringUtils.isEmpty(token)) throw new MessageException(OutputCode.TX.getCode(), OutputCode.TX.getMessage());
String json = redisClusterHelper.get(user_+token);
if(StringUtils.isEmpty(json)){
json = redisClusterHelper.get(APP+token);
if(Strings.isNullOrEmpty(json)){
throw new MessageException(OutputCode.TX.getCode(), OutputCode.TX.getMessage());
}
}
return JSON.parseObject(json,CurrentUserExtInfo.class);
}
新增api接口的区分
自定义注解
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface OpenApi {
String value() default "";
}
对接的接口上添加注解
@OpenApi
@PostMapping("/sync")
public Output<Boolean> sync(@Valid @RequestBody UserReqDTO in) {
return Output.success(userService.sync(in));
}
拦截上针对该注解做处理
新增代码如下
OpenApi openApi = method.getAnnotation(OpenApi.class);
else if(Objects.nonNull(openApi)){
// 判断是否客户端token
hasPermission = authManager.checkOpenApi(token);
}
完整代码如下
public boolean hasPermission(HttpServletRequest request, String token,Object handler) {
boolean hasPermission = false;//默认无权限
if (handler instanceof HandlerMethod) {
HandlerMethod hm = (HandlerMethod) handler;
Method method = hm.getMethod();
PermissionMapping mm = method.getAnnotation(PermissionMapping.class);
OpenApi openApi = method.getAnnotation(OpenApi.class);
if (null != mm) {
boolean isIntercept = mm.isIntercept();
if (isIntercept) {//拦截
String permissionKey = mm.key();
String basePath = "";
String nodePath = "";
Object bean = hm.getBean();
RequestMapping brm = bean.getClass().getAnnotation(RequestMapping.class);
if (null != brm) {
String[] paths = brm.value() == null ? brm.path() : brm.value();
basePath = (null != paths && paths.length > 0) ? paths[0] : "";
}
RequestMapping nrm = method.getAnnotation(RequestMapping.class);
if (null != nrm) {
String[] paths = nrm.value() == null ? nrm.path() : nrm.value();
nodePath = (null != paths && paths.length > 0) ? paths[0] : "";
}
String path = basePath + nodePath;
if(StringUtils.isNotEmpty(path)){
hasPermission = authManager.hasPermission(token, permissionKey);
}
}else{//不拦截
hasPermission=true;//有权限
}
} else if(Objects.nonNull(openApi)){
// 判断是否客户端token
hasPermission = authManager.checkOpenApi(token);
} else{
String permissionKey = request.getServletPath();
if(StringUtils.isNotEmpty(permissionKey)){
hasPermission = authManager.hasPermission(token, permissionKey.substring(1));
}
}
if(hasPermission && StringUtils.isNoneEmpty(token)) {
authManager.putInfo(token);
}
}
return hasPermission;
}
}