文章目录
场景
有一个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; } }