Skip to content
On this page

权限认证框架(UAAC)

一个轻量级 Java 权限认证框架,让鉴权变得简单、优雅!

前言

本文档将会尽力讲解每个功能的设计原因、应用场景,用心阅读文档,你学习到的将不止是 UAAC 框架本身,更是绝大多数场景下权限设计的最佳实践。

UAAC 介绍

UAAC 是一个轻量级 Java 权限认证框架,主要解决:登录认证权限认证Session会话单点登录OAuth2.0微服务网关鉴权 等一系列权限相关问题。

UAAC 的 API 设计非常简单,有多简单呢?以登录认证为例,你只需要:

java
// 在登录时写入当前会话的账号id
StpUtil.login(10001);

// 然后在需要校验登录处调用以下方法:
// 如果当前会话未登录,这句代码会抛出 `NotLoginException` 异常
StpUtil.checkLogin();

至此,我们已经借助 UAAC 完成登录认证!

此时的你小脑袋可能飘满了问号,就这么简单?自定义 Realm 呢?全局过滤器呢?我不用写各种配置文件吗?

没错,在 UAAC 中,登录认证就是如此简单,不需要任何的复杂前置工作,只需这一行简单的API调用,就可以完成会话登录认证!

当你受够 Shiro、SpringSecurity 等框架的三拜九叩之后,你就会明白,相对于这些传统老牌框架,UAAC 的 API 设计是多么的简单、优雅!

权限认证示例(只有具备 user:add 权限的会话才可以进入请求)

java
@CpCheckPermission("user:add")
@RequestMapping("/user/insert")
public String insert(SysUser user) {
	// ... 
	return "用户增加";
}

将某个账号踢下线(待到对方再次访问系统时会抛出NotLoginException异常)

java
// 将账号id为 10001 的会话踢下线 
StpUtil.kickout(10001);

在 UAAC 中,绝大多数功能都可以 一行代码 完成:

java
StpUtil.login(10001);    // 标记当前会话登录的账号id
StpUtil.getLoginId();    // 获取当前会话登录的账号id
StpUtil.isLogin();    // 获取当前会话是否已经登录, 返回true或false
StpUtil.logout();    // 当前会话注销登录
StpUtil.kickout(10001);    // 将账号为10001的会话踢下线
StpUtil.hasRole("super-admin");    // 查询当前账号是否含有指定角色标识, 返回true或false
StpUtil.hasPermission("user:add");    // 查询当前账号是否含有指定权限, 返回true或false
StpUtil.getSession();    // 获取当前账号id的Session
StpUtil.getSessionByLoginId(10001);    // 获取账号id为10001的Session
StpUtil.getTokenValueByLoginId(10001);    // 获取账号id为10001的token令牌值
StpUtil.login(10001, "PC");    // 指定设备标识登录,常用于“同端互斥登录”
StpUtil.kickout(10001, "PC");    // 指定账号指定设备标识踢下线 (不同端不受影响)
StpUtil.openSafe(120);    // 在当前会话开启二级认证,有效期为120秒 
StpUtil.checkSafe();    // 校验当前会话是否处于二级认证有效期内,校验失败会抛出异常 
StpUtil.switchTo(10044);    // 将当前会话身份临时切换为其它账号 

即使不运行测试,相信您也能意会到绝大多数 API 的用法。

UAAC 功能一览

  • 登录认证 —— 单端登录、多端登录、同端互斥登录、七天内免登录
  • 权限认证 —— 权限认证、角色认证、会话二级认证
  • Session会话 —— 全端共享Session、单端独享Session、自定义Session
  • 踢人下线 —— 根据账号id踢人下线、根据Token值踢人下线
  • 账号封禁 —— 指定天数封禁、永久封禁、设定解封时间
  • 持久层扩展 —— 可集成Redis、Memcached等专业缓存中间件,重启数据不丢失
  • 分布式会话 —— 提供jwt集成、共享数据中心两种分布式会话方案
  • 微服务网关鉴权 —— 适配Gateway、ShenYu、Zuul等常见网关的路由拦截认证
  • 单点登录 —— 内置三种单点登录模式:无论是否跨域、是否共享Redis,都可以搞定
  • OAuth2.0认证 —— 基于RFC-6749标准编写,OAuth2.0标准流程的授权认证,支持openid模式
  • 二级认证 —— 在已登录的基础上再次认证,保证安全性
  • Basic认证 —— 一行代码接入 Http Basic 认证
  • 独立Redis —— 将权限缓存与业务缓存分离
  • 临时Token验证 —— 解决短时间的Token授权问题
  • 模拟他人账号 —— 实时操作任意用户状态数据
  • 临时身份切换 —— 将会话身份临时切换为其它账号
  • 前后台分离 —— APP、小程序等不支持Cookie的终端
  • 同端互斥登录 —— 像QQ一样手机电脑同时在线,但是两个手机上互斥登录
  • 多账号认证体系 —— 比如一个商城项目的user表和admin表分开鉴权
  • 花式token生成 —— 内置六种Token风格,还可:自定义Token生成策略、自定义Token前缀
  • 注解式鉴权 —— 优雅的将鉴权与业务代码分离
  • 路由拦截式鉴权 —— 根据路由拦截鉴权,可适配restful模式
  • 自动续签 —— 提供两种Token过期策略,灵活搭配使用,还可自动续签
  • 会话治理 —— 提供方便灵活的会话查询接口
  • 记住我模式 —— 适配[记住我]模式,重启浏览器免验证
  • 密码加密 —— 提供密码加密模块,可快速MD5、SHA1、SHA256、AES、RSA加密
  • 全局侦听器 —— 在用户登陆、注销、被踢下线等关键性操作时进行一些AOP操作
  • 开箱即用 —— 提供SpringMVC、WebFlux等常见web框架starter集成包,真正的开箱即用
  • 更多功能正在集成中... —— 如有您有好想法或者建议,欢迎交流

引入

1、创建项目

在IDE中新建一个SpringBoot项目

2、添加依赖

pom.xml 中添加依赖:

xml
<!-- UAAC 权限认证 -->
<dependency>
	<groupId>com.cpit.csc</groupId>
	<artifactId>uaac-spring-boot-starter</artifactId>
	<version>${uaac.version}</version>
</dependency>
3、设置配置文件

你可以零配置启动项目 ,但同时你也可以在application.yml中增加如下配置,定制性使用框架:

java
server:
	# 端口
    port: 8081
	
### UAAC配置
uaac: 
	# token名称 (同时也是cookie名称)
	token-name: uaac-token
	# token有效期,单位s 默认30天, -1代表永不过期 
	timeout: 2592000
	# token临时有效期 (指定时间内无操作就视为token过期) 单位:
	activity-timeout: -1
	# 是否允许同一账号并发登录 (为true时允许一起登录, 为false时新登录挤掉旧登录) 
	is-concurrent: true
	# 在多人登录同一账号时,是否共用一个token (为true时所有登录共用一个token, 为false时每次登录新建一个token) 
	is-share: false
	# token风格
	token-style: uuid
	# 是否输出操作日志 
	is-log: false
4、创建启动类

在项目中新建包 com.example ,在此包内新建主类 UaacDemoApplication.java,输入以下代码:

java
@SpringBootApplication
public class UaacDemoApplication {
	public static void main(String[] args) throws JsonProcessingException {
		SpringApplication.run(UaacDemoApplication.class, args);
		System.out.println("启动成功:UAAC配置如下:" + CpManager.getConfig());
	}
}
5、创建测试Controller
java
@RestController
@RequestMapping("/user/")
public class UserController {

	// 测试登录,浏览器访问: http://localhost:8081/user/doLogin?username=zhang&password=123456
	@RequestMapping("doLogin")
	public String doLogin(String username, String password) {
		// 此处仅作模拟示例,真实项目需要从数据库中查询数据进行比对 
		if("zhang".equals(username) && "123456".equals(password)) {
			StpUtil.login(10001);
			return "登录成功";
		}
		return "登录失败";
	}

	// 查询登录状态,浏览器访问: http://localhost:8081/user/isLogin
	@RequestMapping("isLogin")
	public String isLogin() {
		return "当前会话是否登录:" + StpUtil.isLogin();
	}
	
}
6、运行

启动代码,从浏览器依次访问上述测试接口:

运行结果

运行结果

详细了解

通过这个示例,你已经对UAAC有了初步的了解,那么现在开始详细了解一下它都有哪些能力 吧

在WebFlux环境集成

Reactor 是一种非阻塞的响应式模型,本篇将以 WebFlux 为例,展示 UAAC 与 Reactor 响应式模型框架相整合的示例, 你可以用同样方式去对接其它Reactor模型框架(Netty、ShenYu、SpringCloud Gateway等)

整合示例在/uaac-demo/uaac-demo-webflux文件夹下,如遇到难点可结合源码进行测试学习

WebFlux常用于微服务网关架构中,如果您的应用基于单体架构且非 Reactor 模型,可以先跳过本章


1、创建项目

在IDE中新建一个SpringBoot项目,例如:uaac-demo-webflux(不会的同学请自行百度或者参考github示例)

2、添加依赖

pom.xml 中添加依赖:

xml
<!-- UAAC 权限认证(Reactor响应式集成) -->
<dependency>
	<groupId>com.cpit.csc</groupId>
	<artifactId>uaac-reactor-spring-boot-starter</artifactId>
	<version>${uaac.top.version}</version>
</dependency>
3、创建启动类

在项目中新建包 com.example ,在此包内新建主类 UaacDemoApplication.java,输入以下代码:

java
@SpringBootApplication
public class UaacDemoApplication {
	public static void main(String[] args) throws JsonProcessingException {
		SpringApplication.run(UaacDemoApplication.class, args);
		System.out.println("启动成功:UAAC配置如下:" + CpManager.getConfig());
	}
}
4、创建全局过滤器

新建CpTokenConfigure.java,注册UAAC的全局过滤器

java
/**
 * [UAAC 权限认证] 全局配置类 
 */
@Configuration
public class CpTokenConfigure {
	/**
     * 注册 [UAAC全局过滤器] 
     */
    @Bean
    public CpReactorFilter getCpReactorFilter() {
        return new CpReactorFilter()
        		// 指定 [拦截路由]
        		.addInclude("/**")
        		// 指定 [放行路由]
        		.addExclude("/favicon.ico")
        		// 指定[认证函数]: 每次请求执行 
        		.setAuth(obj -> {
        			System.out.println("---------- uaac全局认证");
                    // CpRouter.match("/test/test", () -> StpUtil.checkLogin());
        		})
        		// 指定[异常处理函数]:每次[认证函数]发生异常时执行此函数 
        		.setError(e -> {
        			System.out.println("---------- sa全局异常 ");
        			return AjaxJson.getError(e.getMessage());
        		})
        		;
    }
}

你只需要按照此格式复制代码即可,有关过滤器的详细用法,会在之后的章节详细介绍

5、创建测试Controller
java
@RestController
@RequestMapping("/user/")
public class UserController {

	// 测试登录,浏览器访问: http://localhost:8081/user/doLogin?username=zhang&password=123456
	@RequestMapping("doLogin")
	public String doLogin(String username, String password) {
		// 此处仅作模拟示例,真实项目需要从数据库中查询数据进行比对 
		if("zhang".equals(username) && "123456".equals(password)) {
			StpUtil.login(10001);
			return "登录成功";
		}
		return "登录失败";
	}

	// 查询登录状态,浏览器访问: http://localhost:8081/user/isLogin
	@RequestMapping("isLogin")
	public String isLogin() {
		return "当前会话是否登录:" + StpUtil.isLogin();
	}
	
}
6、运行

启动代码,从浏览器依次访问上述测试接口:

运行结果

运行结果

注意事项

更多使用示例请参考官方仓库demo

基础

登录认证


核心思想

所谓登录认证,说白了就是限制某些API接口必须登录后才能访问(例:查询我的账号资料) 那么如何判断一个会话是否登录?框架会在登录成功后给你做个标记,每次登录认证时校验这个标记,有标记者视为已登录,无标记者视为未登录!

登录与注销

根据以上思路,我们很容易想到以下api:

java
// 标记当前会话登录的账号id 
// 建议的参数类型:long | int | String, 不可以传入复杂类型,如:User、Admin等等
StpUtil.login(Object id);	 

// 当前会话注销登录
StpUtil.logout();

// 获取当前会话是否已经登录,返回true=已登录,false=未登录
StpUtil.isLogin();

// 检验当前会话是否已经登录, 如果未登录,则抛出异常:`NotLoginException`
StpUtil.checkLogin()
NotLoginException异常对象扩展:
  1. 通过 getLoginType() 方法获取具体是哪个 StpLogic 抛出的异常
  2. 通过 getType() 方法获取具体的场景值,详细参考<未登录场景值>
会话查询
java
// 获取当前会话账号id, 如果未登录,则抛出异常:`NotLoginException`
StpUtil.getLoginId();

// 类似查询API还有:
StpUtil.getLoginIdAsString();    // 获取当前会话账号id, 并转化为`String`类型
StpUtil.getLoginIdAsInt();       // 获取当前会话账号id, 并转化为`int`类型
StpUtil.getLoginIdAsLong();      // 获取当前会话账号id, 并转化为`long`类型

// ---------- 指定未登录情形下返回的默认值 ----------

// 获取当前会话账号id, 如果未登录,则返回null 
StpUtil.getLoginIdDefaultNull();

// 获取当前会话账号id, 如果未登录,则返回默认值 (`defaultValue`可以为任意类型)
StpUtil.getLoginId(T defaultValue);
其它API
java
// 获取指定token对应的账号id,如果未登录,则返回 null
StpUtil.getLoginIdByToken(String tokenValue);

// 获取当前`StpLogic`的token名称
StpUtil.getTokenName();

// 获取当前会话的token值
StpUtil.getTokenValue();

// 获取当前会话的token信息参数
StpUtil.getTokenInfo();
来个小测试,加深一下理解

新建 LoginController,复制以下代码

java
/**
 * 登录测试 
 *
 */
@RestController
@RequestMapping("/acc/")
public class LoginController {

	// 测试登录  ---- http://localhost:8081/acc/doLogin?name=zhang&pwd=123456
	@RequestMapping("doLogin")
	public CpResult doLogin(String name, String pwd) {
		// 此处仅作模拟示例,真实项目需要从数据库中查询数据进行比对 
		if("zhang".equals(name) && "123456".equals(pwd)) {
			StpUtil.login(10001);
			return CpResult.ok("登录成功");
		}
		return CpResult.error("登录失败");
	}

	// 查询登录状态  ---- http://localhost:8081/acc/isLogin
	@RequestMapping("isLogin")
	public CpResult isLogin() {
		return CpResult.ok("是否登录:" + StpUtil.isLogin());
	}
	
	// 查询 Token 信息  ---- http://localhost:8081/acc/tokenInfo
	@RequestMapping("tokenInfo")
	public CpResult tokenInfo() {
		return CpResult.data(StpUtil.getTokenInfo());
	}
	
	// 测试注销  ---- http://localhost:8081/acc/logout
	@RequestMapping("logout")
	public CpResult logout() {
		StpUtil.logout();
		return CpResult.ok();
	}
	
}

有关TokenInfo参数详解,请参考<TokenInfo参数详解>

权限认证


核心思想

所谓权限认证,认证的核心就是一个账号是否拥有一个权限码 有,就让你通过。没有?那么禁止访问!

再往底了说,就是每个账号都会拥有一个权限码集合,我来校验这个集合中是否包含指定的权限码 例如:当前账号拥有权限码集合:["user-add", "user-delete", "user-get"],这时候我来校验权限 "user-update",则其结果就是:验证失败,禁止访问

所以现在问题的核心就是:

  1. 如何获取一个账号所拥有的的权限码集合
  2. 本次操作需要验证的权限码是哪个
获取当前账号权限码集合

因为每个项目的需求不同,其权限设计也千变万化,因此【获取当前账号权限码集合】这一操作不可能内置到框架中, 所以 UAAC 将此操作以接口的方式暴露给你,以方便的你根据自己的业务逻辑进行重写

你需要做的就是新建一个类,实现StpInterface接口,例如以下代码:

java
package com.example.uaac;

import java.util.ArrayList;
import java.util.List;
import org.springframework.stereotype.Component;
import com.cpit.csc.uaac.stp.StpInterface;

/**
 * 自定义权限验证接口扩展 
 */
@Component	// 保证此类被SpringBoot扫描,完成UAAC的自定义权限验证扩展 
public class StpInterfaceImpl implements StpInterface {

	/**
	 * 返回一个账号所拥有的权限码集合 
	 */
	@Override
	public List<String> getPermissionList(Object loginId, String loginType) {
		// 本list仅做模拟,实际项目中要根据具体业务逻辑来查询权限
		List<String> list = new ArrayList<String>();	
		list.add("101");
		list.add("user-add");
		list.add("user-delete");
		list.add("user-update");
		list.add("user-get");
		list.add("article-get");
		return list;
	}

	/**
	 * 返回一个账号所拥有的角色标识集合 (权限与角色可分开校验)
	 */
	@Override
	public List<String> getRoleList(Object loginId, String loginType) {
		// 本list仅做模拟,实际项目中要根据具体业务逻辑来查询角色
		List<String> list = new ArrayList<String>();	
		list.add("admin");
		list.add("super-admin");
		return list;
	}

}

可参考代码StpInterfaceImpl.java

权限认证

然后就可以用以下api来鉴权了

java
// 判断:当前账号是否含有指定权限, 返回true或false
StpUtil.hasPermission("user-update");		

// 校验:当前账号是否含有指定权限, 如果验证未通过,则抛出异常: NotPermissionException 
StpUtil.checkPermission("user-update");		

// 校验:当前账号是否含有指定权限 [指定多个,必须全部验证通过]
StpUtil.checkPermissionAnd("user-update", "user-delete");		

// 校验:当前账号是否含有指定权限 [指定多个,只要其一验证通过即可]
StpUtil.checkPermissionOr("user-update", "user-delete");		

扩展:NotPermissionException 对象可通过 getLoginType() 方法获取具体是哪个 StpLogic 抛出的异常

角色认证

在UAAC中,角色和权限可以独立验证

java
// 判断:当前账号是否拥有指定角色, 返回true或false
StpUtil.hasRole("super-admin");		

// 校验:当前账号是否含有指定角色标识, 如果验证未通过,则抛出异常: NotRoleException
StpUtil.checkRole("super-admin");		

// 校验:当前账号是否含有指定角色标识 [指定多个,必须全部验证通过]
StpUtil.checkRoleAnd("super-admin", "shop-admin");		

// 校验:当前账号是否含有指定角色标识 [指定多个,只要其一验证通过即可] 
StpUtil.checkRoleOr("super-admin", "shop-admin");		

扩展:NotRoleException 对象可通过 getLoginType() 方法获取具体是哪个 StpLogic 抛出的异常

拦截全局异常

有同学要问,鉴权失败,抛出异常,然后呢?要把异常显示给用户看吗?当然不可以! 你可以创建一个全局异常拦截器,统一返回给前端的格式,参考GlobalException.java

权限通配符

UAAC允许你根据通配符指定泛权限,例如当一个账号拥有user*的权限时,user-adduser-deleteuser-update都将匹配通过

java
// 当拥有 user* 权限时
StpUtil.hasPermission("user-add");        // true
StpUtil.hasPermission("user-update");     // true
StpUtil.hasPermission("art-add");         // false

// 当拥有 *-delete 权限时
StpUtil.hasPermission("user-add");        // false
StpUtil.hasPermission("user-delete");     // true
StpUtil.hasPermission("art-delete");      // true

// 当拥有 *.js 权限时
StpUtil.hasPermission("index.js");        // true
StpUtil.hasPermission("index.css");       // false
StpUtil.hasPermission("index.html");      // false

上帝权限:当一个账号拥有 "*" 权限时,他可以验证通过任何权限码 (角色认证同理)

如何把权限精确搭到按钮级?

权限精确到按钮级的意思就是指:权限范围可以控制到页面上的每一个按钮是否显示

思路:如此精确的范围控制只依赖后端已经难以完成,此时需要前端进行一定的逻辑判断

如果是前后端一体项目,可以参考<Thymeleaf 标签方言>,如果是前后端分离项目,则:

  1. 在登录时,把当前账号拥有的所有权限码一次性返回给前端
  2. 前端将权限码集合保存在localStorage或其它全局状态管理对象中
  3. 在需要权限控制的按钮上,使用js进行逻辑判断,例如在vue框架中我们可以使用如下写法:
js
<button v-if="arr.indexOf('user:delete') > -1">删除按钮</button>

其中:arr是当前用户拥有的权限码数组,user:delete是显示按钮需要拥有的权限码,删除按钮是用户拥有权限码才可以看到的内容

注意:以上写法只为提供一个参考示例,不同框架有不同写法,开发者可根据项目技术栈灵活封装进行调用

前端有了鉴权后端还需要鉴权吗?

需要!

前端的鉴权只是一个辅助功能,对于专业人员这些限制都是可以轻松绕过的,为保证服务器安全,无论前端是否进行了权限校验,后端接口都需要对会话请求再次进行权限校验!

踢人下线

所谓踢人下线,核心操作就是找到其指定loginId对应的token,并设置其失效


1、强制注销
java
StpUtil.logout(10001);                    // 强制指定账号注销下线 
StpUtil.logout(10001, "PC");              // 强制指定账号指定端注销下线 
StpUtil.logoutByTokenValue("token");      // 强制指定 Token 注销下线 
2、踢人下线
java
StpUtil.kickout(10001);                    // 将指定账号踢下线 
StpUtil.kickout(10001, "PC");              // 将指定账号指定端踢下线
StpUtil.kickoutByTokenValue("token");      // 将指定 Token 踢下线

强制注销 和 踢人下线 的区别在于:

  • 强制注销等价于对方主动调用了注销方法,再次访问会提示:Token无效。
  • 踢人下线不会清除Token信息,而是将其打上特定标记,再次访问会提示:Token已被踢下线。
3、账号封禁

对于违规账号,有时候我们仅仅将其踢下线还是远远不够的,我们还需要对其进行账号封禁防止其再次登录

java
// 封禁指定账号 
// 参数一:账号id
// 参数二:封禁时长,单位:秒  (86400秒=1天,此值为-1时,代表永久封禁)
StpUtil.disable(10001, 86400); 

// 获取指定账号是否已被封禁 (true=已被封禁, false=未被封禁) 
StpUtil.isDisable(10001); 

// 获取指定账号剩余封禁时间,单位:秒
StpUtil.getDisableTime(10001); 

// 解除封禁
StpUtil.untieDisable(10001); 
注意点

对于正在登录的账号,对其账号封禁时并不会使其立刻注销 如果需要将其封禁后立即掉线,可采取先踢再封禁的策略,例如:

java
// 先踢下线
StpUtil.kickout(10001); 
// 再封禁账号
StpUtil.disable(10001, 86400); 

注解式鉴权


有同学表示:尽管使用代码鉴权非常方便,但是我仍希望把鉴权逻辑和业务逻辑分离开来,我可以使用注解鉴权吗?当然可以!

注解鉴权 —— 优雅的将鉴权与业务代码分离!

  • @CpCheckLogin: 登录认证 —— 只有登录之后才能进入该方法
  • @CpCheckRole("admin"): 角色认证 —— 必须具有指定角色标识才能进入该方法
  • @CpCheckPermission("user:add"): 权限认证 —— 必须具有指定权限才能进入该方法
  • @CpCheckSafe: 二级认证校验 —— 必须二级认证之后才能进入该方法
  • @CpCheckBasic: HttpBasic认证 —— 只有通过 Basic 认证后才能进入该方法

UAAC使用全局拦截器完成注解鉴权功能,为了不为项目带来不必要的性能负担,拦截器默认处于关闭状态 因此,为了使用注解鉴权,你必须手动将UAAC的全局拦截器注册到你项目中

注:UAAC内置两种模式完成注解鉴权,分别是拦截器模式AOP模式, 为了避免不必要的性能浪费,这两种模式默认都处于关闭状态 因此如若使用注解鉴权,你必须选择其一进行注册

1、注册拦截器

SpringBoot2.0为例, 新建配置类CpConfigure.java

java
@Configuration
public class CpConfigure implements WebMvcConfigurer {
	// 注册UAAC的注解拦截器,打开注解式鉴权功能 
	@Override
	public void addInterceptors(InterceptorRegistry registry) {
		// 注册注解拦截器,并排除不需要注解鉴权的接口地址 (与登录拦截器无关)
		registry.addInterceptor(new CpAnnotationInterceptor()).addPathPatterns("/**");	
	}
}

保证此类被springboot启动类扫描到即可

2、使用注解鉴权

然后我们就可以愉快的使用注解鉴权:

java
// 登录认证:只有登录之后才能进入该方法 
@CpCheckLogin						
@RequestMapping("info")
public String info() {
	return "查询用户信息";
}

// 角色认证:必须具有指定角色才能进入该方法 
@CpCheckRole("super-admin")		
@RequestMapping("add")
public String add() {
	return "用户增加";
}

// 权限认证:必须具有指定权限才能进入该方法 
@CpCheckPermission("user-add")		
@RequestMapping("add")
public String add() {
	return "用户增加";
}

// 二级认证:必须二级认证之后才能进入该方法 
@CpCheckSafe()		
@RequestMapping("add")
public String add() {
	return "用户增加";
}

// Http Basic 认证:只有通过 Basic 认证后才能进入该方法 
@CpCheckBasic(account = "uaac:123456")
@RequestMapping("add")
public String add() {
	return "用户增加";
}

注:以上注解都可以加在类上,代表为这个类所有方法进行鉴权

3、设定校验模式

@CpCheckRole@CpCheckPermission注解可设置校验模式,例如:

java
// 注解式鉴权:只要具有其中一个权限即可通过校验 
@RequestMapping("atJurOr")
@CpCheckPermission(value = {"user-add", "user-all", "user-delete"}, mode = CpMode.OR)		
public AjaxJson atJurOr() {
	return AjaxJson.getSuccessData("用户信息");
}

mode有两种取值:

  • CpMode.AND, 标注一组权限,会话必须全部具有才可通过校验
  • CpMode.OR, 标注一组权限,会话只要具有其一即可通过校验
4、角色权限双重 “or校验”

假设有以下业务场景:一个接口在具体权限 user-add 或角色 admin 时可以调通。怎么写?

java
// 注解式鉴权:只要具有其中一个权限即可通过校验 
@RequestMapping("userAdd")
@CpCheckPermission(value = "user-add", orRole = "admin")		
public AjaxJson userAdd() {
	return AjaxJson.getSuccessData("用户信息");
}

orRole 字段代表权限认证未通过时的次要选择,两者只要其一认证成功即可通过校验,其有三种写法:

  • 写法一:orRole = "admin",代表需要拥有角色 admin 。
  • 写法二:orRole = {"admin", "manager", "staff"},代表具有三个角色其一即可。
  • 写法三:orRole = {"admin, manager, staff"},代表必须同时具有三个角色。
5、在业务逻辑层使用注解鉴权

疑问:我能否将注解写在其它架构层呢,比如业务逻辑层?

使用拦截器模式,只能在Controller层进行注解鉴权,如需在任意层级使用注解鉴权,请参考<AOP注解鉴权>

路由拦截鉴权


假设我们有如下需求:

项目中所有接口均需要登录认证,只有'登录接口'本身对外开放

我们怎么实现呢?给每个接口加上鉴权注解?手写全局拦截器?似乎都不是非常方便。 在这个需求中我们真正需要的是一种基于路由拦截的鉴权模式, 那么在UAAC怎么实现路由拦截鉴权呢?

1、注册 UAAC 路由拦截器

SpringBoot2.0为例, 新建配置类CpConfigure.java

java
@Configuration
public class CpConfigure implements WebMvcConfigurer {
	// 注册拦截器
	@Override
	public void addInterceptors(InterceptorRegistry registry) {
		// 注册UAAC的路由拦截器
		registry.addInterceptor(new CpRouteInterceptor())
			.addPathPatterns("/**")
			.excludePathPatterns("/user/doLogin"); 
	}
}

以上代码,我们注册了一个登录认证拦截器,并且排除了/user/doLogin接口用来开放登录(除了/user/doLogin以外的所有接口都需要登录才能访问) 那么我们如何进行权限认证拦截呢,且往下看

2、校验函数详解

你可以使用函数式编程自定义认证规则,例如:

java
@Configuration
public class CpTokenConfigure implements WebMvcConfigurer {
	@Override
	public void addInterceptors(InterceptorRegistry registry) {
		// 注册路由拦截器,自定义认证规则 
		registry.addInterceptor(new CpRouteInterceptor((req, res, handler)->{
			// 根据路由划分模块,不同模块不同鉴权 
			CpRouter.match("/user/**", r -> StpUtil.checkPermission("user"));
			CpRouter.match("/admin/**", r -> StpUtil.checkPermission("admin"));
			CpRouter.match("/goods/**", r -> StpUtil.checkPermission("goods"));
			CpRouter.match("/orders/**", r -> StpUtil.checkPermission("orders"));
			CpRouter.match("/notice/**", r -> StpUtil.checkPermission("notice"));
			CpRouter.match("/comment/**", r -> StpUtil.checkPermission("comment"));
		})).addPathPatterns("/**");
	}
}

CpRouter.match() 匹配函数有两个参数:

  • 参数一:要匹配的path路由。
  • 参数二:要执行的校验函数。

在校验函数内不只可以使用 StpUtil.checkPermission("xxx") 进行权限校验,你还可以写任意代码,例如:

java
@Configuration
public class CpTokenConfigure implements WebMvcConfigurer {
	// 注册UAAC的拦截器
	@Override
	public void addInterceptors(InterceptorRegistry registry) {
		// 注册路由拦截器,自定义认证规则 
		registry.addInterceptor(new CpRouteInterceptor((req, res, handler) -> {
			
			// 登录认证 -- 拦截所有路由,并排除/user/doLogin 用于开放登录 
			CpRouter.match("/**", "/user/doLogin", r -> StpUtil.checkLogin());

			// 角色认证 -- 拦截以 admin 开头的路由,必须具备 admin 角色或者 super-admin 角色才可以通过认证 
			CpRouter.match("/admin/**", r -> StpUtil.checkRoleOr("admin", "super-admin"));

			// 权限认证 -- 不同模块认证不同权限 
			CpRouter.match("/user/**", r -> StpUtil.checkPermission("user"));
			CpRouter.match("/admin/**", r -> StpUtil.checkPermission("admin"));
			CpRouter.match("/goods/**", r -> StpUtil.checkPermission("goods"));
			CpRouter.match("/orders/**", r -> StpUtil.checkPermission("orders"));
			CpRouter.match("/notice/**", r -> StpUtil.checkPermission("notice"));
			CpRouter.match("/comment/**", r -> StpUtil.checkPermission("comment"));
			
			// 甚至你可以随意的写一个打印语句
			CpRouter.match("/**", r -> System.out.println("----啦啦啦----"));

            // 连缀写法
            CpRouter.match("/**").check(r -> System.out.println("----啦啦啦----"));
			
		})).addPathPatterns("/**");
	}
}
3、匹配特征详解

除了上述示例的 path 路由匹配,还可以根据很多其它特征进行匹配,以下是所有可匹配的特征:

java
// 基础写法样例:匹配一个path,执行一个校验函数 
CpRouter.match("/user/**").check(r -> StpUtil.checkLogin());

// 根据 path 路由匹配   ——— 支持写多个path,支持写 restful 风格路由 
CpRouter.match("/user/**", "/goods/**", "/art/get/{id}").check( /* 要执行的校验函数 */ );

// 根据 path 路由排除匹配 
CpRouter.match("/**").notMatch("*.html", "*.css", "*.js").check( /* 要执行的校验函数 */ );

// 根据请求类型匹配 
CpRouter.match(CpHttpMethod.GET).check( /* 要执行的校验函数 */ );

// 根据一个 boolean 条件进行匹配 
CpRouter.match( StpUtil.isLogin() ).check( /* 要执行的校验函数 */ );

// 根据一个返回 boolean 结果的lambda表达式匹配 
CpRouter.match( r -> StpUtil.isLogin() ).check( /* 要执行的校验函数 */ );

// 多个条件一起使用 
CpRouter.match(CpHttpMethod.GET).match("/**").check( /* 要执行的校验函数 */ );

// 可以无限连缀下去 
CpRouter
	.match(CpHttpMethod.GET)
	.match("/admin/**")
	.match("/user/**") 
	.notMatch("/**/*.js")
	.notMatch("/**/*.css")
	// ....
	.check( /* 只有上述所有条件都匹配成功,才会执行最后的check校验函数 */ );
4、提前退出匹配链

使用 CpRouter.stop() 可以提前退出匹配链,例:

java
registry.addInterceptor(new CpRouteInterceptor((req, res, handler) -> {
	CpRouter.match("/**").check(r -> System.out.println("进入1"));
	CpRouter.match("/**").check(r -> System.out.println("进入2")).stop();
	CpRouter.match("/**").check(r -> System.out.println("进入3"));
})).addPathPatterns("/**");

如上示例,代码运行至第2条匹配链时,会在stop函数处提前退出整个匹配函数,从而忽略掉剩余的所有match匹配

除了stop()函数,CpRouter还提供了 back() 函数,用于:停止匹配,结束执行,直接向前端返回结果

java
// 执行back函数后将停止匹配,也不会进入Controller,而是直接将 back参数 作为返回值输出到前端
CpRouter.match("/user/back").back("参数");

stop() 与 back() 函数的区别在于:

  • CpRouter.stop() 会停止匹配,进入Controller。
  • CpRouter.back() 会停止匹配,直接返回结果到前端。
5、使用free打开一个独立的作用域
java
// 进入 free 独立作用域 
CpRouter.match("/**").free(r -> {
	CpRouter.match("/a/**").check(/* --- */);
	CpRouter.match("/a/**").check(/* --- */).stop();
	CpRouter.match("/a/**").check(/* --- */);
});
// 执行 stop() 函数跳出 free 后继续执行下面的 match 匹配 
CpRouter.match("/**").check(/* --- */);

free() 的作用是:打开一个独立的作用域,使内部的 stop() 不再一次性跳出整个 Auth 函数,而是仅仅跳出当前 free 作用域。

Session会话


Session是什么?

Session是会话中专业的数据缓存组件,通过 Session 我们可以很方便的缓存一些高频读写数据,提高程序性能,例如:

java
// 在登录时缓存user对象 
StpUtil.getSession().set("user", user);

// 然后我们就可以在任意处使用这个user对象
SysUser user = (SysUser) StpUtil.getSession().get("user");

在 UAAC 中,Session 分为三种,分别是:

  • User-Session: 指的是框架为每个 账号id 分配的 Session
  • Token-Session: 指的是框架为每个 token 分配的 Session
  • Custom-Session: 指的是以一个 特定的值 作为SessionId,来分配的 Session

有关User-Session与Token-Session的详细区别,请参考<Session模型详解>

User-Session

有关账号Session的API如下:

java
// 获取当前账号id的Session (必须是登录后才能调用)
StpUtil.getSession();

// 获取当前账号id的Session, 并决定在Session尚未创建时,是否新建并返回
StpUtil.getSession(true);

// 获取账号id为10001的Session
StpUtil.getSessionByLoginId(10001);

// 获取账号id为10001的Session, 并决定在Session尚未创建时,是否新建并返回
StpUtil.getSessionByLoginId(10001, true);

// 获取SessionId为xxxx-xxxx的Session, 在Session尚未创建时, 返回null 
StpUtil.getSessionBySessionId("xxxx-xxxx");
Token-Session

有关令牌Session的API如下:

java
// 获取当前token的专属Session 
StpUtil.getTokenSession();

// 获取指定token的专属Session 
StpUtil.getTokenSessionByToken(token);

在未登录状态下是否可以获取Token-Session?这取决于你配置的tokenSessionCheckLogin值是否为false,详见<框架配置>

自定义Session

自定义Session指的是以一个特定的值作为SessionId来分配的Session, 借助自定义Session,你可以为系统中的任意元素分配相应的session 例如以商品id作为key为每个商品分配一个Session,以便于缓存和商品相关的数据,其相关API如下:

java
// 查询指定key的Session是否存在
CpSessionCustomUtil.isExists("goods-10001");

// 获取指定key的Session,如果没有,则新建并返回
CpSessionCustomUtil.getSessionById("goods-10001");

// 获取指定key的Session,如果没有,第二个参数决定是否新建并返回  
CpSessionCustomUtil.getSessionById("goods-10001", false);   

// 删除指定key的Session
CpSessionCustomUtil.deleteSessionById("goods-10001");
Session相关操作

那么获取到的CpSession具体有哪些方法可供操作?

java
// 返回此Session的id 
session.getId();                          

// 返回此Session的创建时间 (时间戳) 
session.getCreateTime();                  

// 在Session上获取一个值 
session.getAttribute('name');             

// 在Session上获取一个值,并指定取不到值时返回的默认值
session.getAttribute('name', 'zhang');    

// 在Session上写入一个值 
session.setAttribute('name', 'zhang');    

// 在Session上移除一个值 
session.removeAttribute('name');          

// 清空此Session的所有值 
session.clearAttribute();                 

// 获取此Session是否含有指定key (返回true或false)
session.containsAttribute('name');        

// 获取此Session会话上所有key (返回Set<String>)
session.attributeKeys();                  

// 返回此Session会话上的底层数据对象(如果更新map里的值,请调用session.update()方法避免产生脏数据)
session.getDataMap();                     

// 将这个Session从持久库更新一下
session.update();                         

// 注销此Session会话 (从持久库删除此Session)
session.logout();                         
类型转换API

由于Session存取值默认的类型都是Object,因此我们通常会写很多不必要类型转换代码 为了简化操作,UAAC自封装了存取值API的类型转换,你可以非常方便的调用以下方法:

java
// 写值 
session.set("name", "zhang"); 

// 写值 (只有在此key原本无值的时候才会写入)
session.setDefaultValue("name", "zhang");

// 取值
session.get("name");

// 取值 (指定默认值)
session.get("name", "<defaultValue>"); 

// 取值 (转String类型)
session.getString("name"); 

// 取值 (转int类型)
session.getInt("age"); 

// 取值 (转long类型)
session.getLong("age"); 

// 取值 (转double类型)
session.getDouble("result"); 

// 取值 (转float类型)
session.getFloat("result"); 

// 取值 (指定转换类型)
session.getModel("key", Student.class); 

// 取值 (指定转换类型, 并指定值为Null时返回的默认值)
session.getModel("key", Student.class, <defaultValue>); 

// 是否含有某个key
session.has("key"); 
Session环境隔离说明

有同学经常会把 CpSessionHttpSession 进行混淆,例如:

java
@PostMapping("/resetPoints")
public void reset(HttpSession session) {
	// 在HttpSession上写入一个值 
    session.setAttribute("name", 66);
	// 在CpSession进行取值
    System.out.println(StpUtil.getSession().getAttribute("name"));	// 输出null
}

要点:

  1. CpSessionHttpSession 没有任何关系,在HttpSession上写入的值,在CpSession中无法取出
  2. HttpSession并未被框架接管,在使用UAAC时,请在任何情况下均使用CpSession,不要使用HttpSession

框架配置

你可以零配置启动框架 但同时你也可以通过配置,定制性使用框架,UAAC支持多种方式配置框架信息

方式1、在 application.yml 配置
java
### UAAC 配置
uaac: 
	# token名称 (同时也是cookie名称)
	token-name: uaac-token
	# token有效期,单位s 默认30天, -1代表永不过期 
	timeout: 2592000
	# token临时有效期 (指定时间内无操作就视为token过期) 单位:
	activity-timeout: -1
	# 是否允许同一账号并发登录 (为true时允许一起登录, 为false时新登录挤掉旧登录) 
	is-concurrent: true
	# 在多人登录同一账号时,是否共用一个token (为true时所有登录共用一个token, 为false时每次登录新建一个token) 
	is-share: true
	# token风格
	token-style: uuid
	# 是否输出操作日志 
	is-log: false

如果你习惯于 application.properties 类型的配置文件,那也很好办: 百度: springboot properties与yml 配置文件的区别

方式2、通过代码配置

模式1:

java
/**
 * UAAC代码方式进行配置
 */
@Configuration
public class CpTokenConfigure {

	// 获取配置Bean (以代码的方式配置UAAC, 此配置会覆盖yml中的配置)
    @Bean
    @Primary
    public CpConfig getCpConfigPrimary() {
		CpConfig config = new CpConfig();
		config.setTokenName("uaac-token");             // token名称 (同时也是cookie名称)
		config.setTimeout(30 * 24 * 60 * 60);       // token有效期,单位s 默认30天
		config.setActivityTimeout(-1);              // token临时有效期 (指定时间内无操作就视为token过期) 单位: 秒
		config.setIsConcurrent(true);               // 是否允许同一账号并发登录 (为true时允许一起登录, 为false时新登录挤掉旧登录) 
		config.setIsShare(true);                    // 在多人登录同一账号时,是否共用一个token (为true时所有登录共用一个token, 为false时每次登录新建一个token) 
		config.setTokenStyle("uuid");               // token风格 
		config.setIsLog(false);                     // 是否输出操作日志 
		return config;
	}
	
}

模式2:

java
// 以代码的方式配置UAAC-Config 
@Autowired
public void configUAAC(CpConfig config) {
	// config.setTokenName("uaac-token333");             // token名称 (同时也是cookie名称)
	// ... 
}

PS:两者的区别在于:模式1会覆盖yml中的配置,模式2会与yml中的配置合并


所有可配置项
参数名称类型默认值说明
tokenNameStringuaac-tokentoken名称 (同时也是cookie名称)
timeoutlong2592000token有效期,单位/秒 默认30天,-1代表永久有效 参考<token有效期详解>
activityTimeoutlong-1token临时有效期 (指定时间内无操作就视为token过期) 单位: 秒, 默认-1 代表不限制 (例如可以设置为1800代表30分钟内无操作就过期) 参考<token有效期详解>
isConcurrentBooleantrue是否允许同一账号并发登录 (为true时允许一起登录, 为false时新登录挤掉旧登录)
isShareBooleantrue在多人登录同一账号时,是否共用一个token (为true时所有登录共用一个token, 为false时每次登录新建一个token)
isReadBodyBooleantrue是否尝试从 请求体 里读取 Token
isReadHeadBooleantrue是否尝试从 header 里读取 Token
isReadCookieBooleantrue是否尝试从 cookie 里读取 Token
tokenStyleStringuuidtoken风格, 参考<自定义Token风格>
dataRefreshPeriodint30默认dao层实现类中,每次清理过期数据间隔的时间 (单位: 秒) ,默认值30秒,设置为-1代表不启动定时清理
tokenSessionCheckLoginBooleantrue获取 Token-Session 时是否必须登录 (如果配置为true,会在每次获取 Token-Session 时校验是否登录)
autoRenewBooleantrue是否打开自动续签 (如果此值为true, 框架会在每次直接或间接调用 getLoginId() 时进行一次过期检查与续签操作)
tokenPrefixStringnulltoken前缀, 例如填写 Bearer 实际传参 uaac-token: Bearer xxxx-xxxx-xxxx-xxxx 参考<自定义Token前缀>
isPrintBooleantrue是否在初始化配置时打印版本字符画
isLogBooleanfalse是否打印操作日志
jwtSecretKeyStringnulljwt秘钥 (只有集成 uaac-temp-jwt 模块时此参数才会生效)
idTokenTimeoutlong86400Id-Token的有效期 (单位: 秒)
basicString""Http Basic 认证的账号和密码 参考<Http Basic 认证>
currDomainBooleanfalse是否校验Id-Token(部分rpc插件有效)
checkIdTokenfalsefalse配置当前项目的网络访问地址
ssoObjectnew CpSsoConfig()SSO 单点登录相关配置
cookieObjectnew SaCookieConfig()Cookie配置对象

Cookie相关配置:

参数名称类型默认值说明
domainStringnull作用域(写入Cookie时显式指定的作用域, 常用于单点登录二级域名共享Cookie的场景)
pathString/路径,默认写在域名根路径下
secureBooleanfalse是否只在 https 协议下有效
httpOnlyBooleanfalse是否禁止 js 操作 Cookie
sameSiteStringLax第三方限制级别(Strict=完全禁止,Lax=部分允许,None=不限制)
单点登录相关配置

Server 端:

参数名称类型默认值说明
ticketTimeoutlong300ticket 有效期 (单位: 秒)
allowUrlString*所有允许的授权回调地址,多个用逗号隔开(不在此列表中的URL将禁止下放ticket),参考:<SSO整合:配置域名校验>
isSloBooleanfalse是否打开单点注销功能
isHttpBooleanfalse是否打开模式三(此值为 true 时将使用 http 请求:校验ticket值、单点注销、获取userinfo)
secretkeyStringnull调用秘钥 (用于SSO模式三单点注销的接口通信身份校验)

Client 端:

参数名称类型默认值说明
authUrlStringnull配置 Server 端单点登录授权地址
isSloBooleanfalse是否打开单点注销功能
isHttpBooleanfalse是否打开模式三(此值为 true 时将使用 http 请求:校验ticket值、单点注销、获取userinfo)
checkTicketUrlStringnull配置 Server 端的 ticket 校验地址
userinfoUrlStringnull配置 Server 端查询 userinfo 地址
sloUrlStringnull配置 Server 端单点注销地址
ssoLogoutCallStringnull配置当前 Client 端的单点注销回调URL (为空时自动获取)
secretkeyStringnull接口调用秘钥 (用于SSO模式三单点注销的接口通信身份校验)

配置示例:

yml
### UAAC 配置
uaac: 
    # SSO-相关配置
    sso: 
        # SSO-Server端 单点登录授权地址 
        auth-url: http://uaac-sso-server.com:9000/sso/auth
OAuth2.0相关配置
参数名称类型默认值说明
isCodeBooleantrue是否打开模式:授权码(Authorization Code)
isImplicitBooleanfalse是否打开模式:隐藏式(Implicit)
isPasswordBooleanfalse是否打开模式:密码式(Password)
isClientBooleanfalse是否打开模式:凭证式(Client Credentials)
isNewRefreshBooleanfalse是否在每次 Refresh-Token 刷新 Access-Token 时,产生一个新的 Refresh-Token
codeTimeoutlong300Code授权码 保存的时间(单位秒) 默认五分钟
accessTokenTimeoutlong7200Access-Token 保存的时间(单位秒) 默认两个小时
refreshTokenTimeoutlong2592000Refresh-Token 保存的时间(单位秒) 默认30 天
clientTokenTimeoutlong7200Client-Token 保存的时间(单位秒) 默认两个小时

配置示例:

yml
### UAAC 配置
uaac: 
    token-name: uaac-token-server
    # OAuth2.0 配置 
    oauth2: 
        is-code: true
        is-implicit: true
        is-password: true
        is-client: true

深入

集成 Redis


UAAC默认将数据保存在内存中,此模式读写速度最快,且避免了序列化与反序列化带来的性能消耗,但是此模式也有一些缺点,比如:

  1. 重启后数据会丢失
  2. 无法在分布式环境中共享数据

为此,UAAC提供了扩展接口,你可以轻松将会话数据存储在 RedisMemcached等专业的缓存中间件中, 做到重启数据不丢失,而且保证分布式环境下多节点的会话一致性

以下是官方提供的Redis集成包:


方式1. UAAC 整合 Redis (使用jdk默认序列化方式)
xml
<!-- UAAC 整合 Redis (使用jdk默认序列化方式) -->
<dependency>
	<groupId>com.cpit.csc</groupId>
	<artifactId>uaac-dao-redis</artifactId>
	<version>${uaac.top.version}</version>
</dependency>

优点:兼容性好,缺点:Session序列化后基本不可读,对开发者来讲等同于乱码

方式2. UAAC 整合 Redis(使用jackson序列化方式)
xml
<!-- UAAC 整合 Redis (使用jackson序列化方式) -->
<dependency>
	<groupId>com.cpit.csc</groupId>
	<artifactId>uaac-dao-redis-jackson</artifactId>
	<version>${uaac.top.version}</version>
</dependency>

优点:Session序列化后可读性强,可灵活手动修改,缺点:兼容性稍差

集成Redis请注意:

1. 无论使用哪种序列化方式,你都必须为项目提供一个Redis实例化方案,例如:

xml
<!-- 提供Redis连接池 -->
<dependency>
	<groupId>org.apache.commons</groupId>
	<artifactId>commons-pool2</artifactId>
</dependency>

2. 引入了依赖,我还需要为Redis配置连接信息吗? 需要!只有项目初始化了正确的Redis实例,UAAC才可以使用Redis进行数据持久化,参考以下yml配置

java
### 端口
spring: 
    # redis配置 
    redis:
        # Redis数据库索引(默认为0)
        database: 1
        # Redis服务器地址
        host: 127.0.0.1
        # Redis服务器连接端口
        port: 6379
        # Redis服务器连接密码(默认为空)
        # password: 
        # 连接超时时间
        timeout: 10s
        lettuce:
            pool:
                # 连接池最大连接数
                max-active: 200
                # 连接池最大阻塞等待时间(使用负值表示没有限制)
                max-wait: -1ms
                # 连接池中的最大空闲连接
                max-idle: 10
                # 连接池中的最小空闲连接
                min-idle: 0

3. 集成Redis后,是我额外手动保存数据,还是框架自动保存? 框架自动保存。集成Redis只需要引入对应的pom依赖即可,框架所有上层API保持不变

4. 集成包版本问题 UAAC-Redis 集成包的版本尽量与 UAAC-Starter 集成包的版本一致,否则可能出现兼容性问题

更多框架的集成方案正在更新中...

前后台分离


何为无Cookie模式?

无Cookie:特指不支持Cookie功能的终端,通俗来讲就是我们常说的 —— 前后台分离模式

常规PC端鉴权方法,一般由Cookie模式完成,而 Cookie 有两个特性:

  1. 可由后端控制写入
  2. 每次请求自动提交

这就使得我们在前端代码中,无需任何特殊操作,就能完成鉴权的全部流程(因为整个流程都是后端控制完成的) 而在app、小程序等前后台分离场景中,一般是没有 Cookie 这一功能的,此时大多数人都会一脸懵逼,咋进行鉴权啊?

见招拆招,其实答案很简单:

  • 不能后端控制写入了,就前端自己写入(难点在后端如何将token传递到前端
  • 每次请求不能自动提交了,那就手动提交(难点在前端如何将token传递到后端,同时后端将其读取出来
1、后端将 token 返回到前端
  1. 首先调用 StpUtil.login(id) 进行登录
  2. 调用 StpUtil.getTokenInfo() 返回当前会话的token详细参数
  • 此方法返回一个对象,其有两个关键属性:tokenNametokenValue(token 的名称和 token 的值)
  • 将此对象传递到前台,让前端人员将这两个值保存到本地
2、前端将 token 提交到后端
  1. 无论是app还是小程序,其传递方式都大同小异
  2. 那就是,将 token 塞到请求header里 ,格式为:{tokenName: tokenValue}
  3. 以经典跨端框架 uni-app 为例:

方式1,简单粗暴

js
// 1、首先在登录时,将 tokenValue 存储在本地,例如:
uni.setStorageSync('tokenValue', tokenValue);

// 2、在发起ajax请求的地方,获取这个值,并塞到header里 
uni.request({
	url: 'https://www.example.com/request', // 仅为示例,并非真实接口地址。
	header: {
		"content-type": "application/x-www-form-urlencoded",
		"uaac-token": uni.getStorageSync('tokenValue')		// 关键代码, 注意参数名字是 uaac-token 
	},
	success: (res) => {
		console.log(res.data);	
	}
});

方式2,更加灵活

js
// 1、首先在登录时,将tokenName和tokenValue一起存储在本地,例如:
uni.setStorageSync('tokenName', tokenName); 
uni.setStorageSync('tokenValue', tokenValue); 

// 2、在发起ajax的地方,获取这两个值, 并组织到head里 
var tokenName = uni.getStorageSync('tokenName');	// 从本地缓存读取tokenName值
var tokenValue = uni.getStorageSync('tokenValue');	// 从本地缓存读取tokenValue值
var header = {
	"content-type": "application/x-www-form-urlencoded"
};
if (tokenName != undefined && tokenName != '') {
	header[tokenName] = tokenValue;
}

// 3、后续在发起请求时将 header 对象塞到请求头部 
uni.request({
	url: 'https://www.example.com/request', // 仅为示例,并非真实接口地址。
	header: header,
	success: (res) => {
		console.log(res.data);	
	}
});
  1. 只要按照如此方法将token值传递到后端,UAAC 就能像传统PC端一样自动读取到 token 值,进行鉴权
  2. 你可能会有疑问,难道我每个ajax都要写这么一坨?岂不是麻烦死了
  • 你当然不能每个 ajax 都写这么一坨,因为这种重复性代码都是要封装在一个函数里统一调用的
其它解决方案?

如果你对 Cookie 非常了解,那你就会明白,所谓 Cookie ,本质上就是一个特殊的header参数而已, 而既然它只是一个 header 参数,我们就能手动模拟实现它,从而完成鉴权操作

这其实是对无Cookie模式的另一种解决方案,有兴趣的同学可以百度了解一下,在此暂不赘述

自定义 Token 风格

本篇介绍token生成的各种风格,以及自定义token生成策略


内置风格

UAAC默认的token生成策略是uuid风格,其模样类似于:623368f0-ae5e-4475-a53f-93e4225f16ae 如果你对这种风格不太感冒,还可以将token生成设置为其他风格

怎么设置呢?只需要在yml配置文件里设置 uaac.token-style=风格类型 即可,其有多种取值:

java
// 1. token-style=uuid    —— uuid风格 (默认风格)
"623368f0-ae5e-4475-a53f-93e4225f16ae"

// 2. token-style=simple-uuid    —— 同上,uuid风格, 只不过去掉了中划线
"6fd4221395024b5f87edd34bc3258ee8"

// 3. token-style=random-32    —— 随机32位字符串
"qEjyPsEA1Bkc9dr8YP6okFr5umCZNR6W"

// 4. token-style=random-64    —— 随机64位字符串
"v4ueNLEpPwMtmOPMBtOOeIQsvP8z9gkMgIVibTUVjkrNrlfra5CGwQkViDjO8jcc"

// 5. token-style=random-128    —— 随机128位字符串
"nojYPmcEtrFEaN0Otpssa8I8jpk8FO53UcMZkCP9qyoHaDbKS6dxoRPky9c6QlftQ0pdzxRGXsKZmUSrPeZBOD6kJFfmfgiRyUmYWcj4WU4SSP2ilakWN1HYnIuX0Olj"

// 6. token-style=tik    —— tik风格
"gr_SwoIN0MC1ewxHX_vfCW3BothWDZMMtx__"

自定义token生成策略

如果你觉着以上风格都不是你喜欢的类型,那么你还可以自定义token生成策略,来定制化token生成风格

怎么做呢?只需要重写 CpStrategy 策略类的 createToken 算法即可

参考步骤如下:

1、在CpTokenConfigure配置类中添加代码:

java
@Configuration
public class CpTokenConfigure {
    /**
     * 重写 UAAC 框架内部算法策略 
     */
    @Autowired
    public void rewriteCpStrategy() {
    	// 重写 Token 生成策略 
    	CpStrategy.me.createToken = (loginId, loginType) -> {
    		return CpFoxUtil.getRandomString(60);	// 随机60位长度字符串
    	};
    }
}

2、再次调用 StpUtil.login(10001)方法进行登录,观察其生成的token样式:

html
gfuPSwZsnUhwgz08GTCH4wOgasWtc3odP4HLwXJ7NDGOximTvT4OlW19zeLH

自定义 Token 前缀

需求场景

在某些系统中,前端提交token时会在前面加个固定的前缀,例如:

js
{
	"uaac-token": "Bearer xxxx-xxxx-xxxx-xxxx"
}

此时后端如果不做任何特殊处理,框架将会把Bearer 视为token的一部分,无法正常读取token信息,导致鉴权失败

为此,我们需要在yml中添加如下配置:

java
uaac: 
	# token前缀
	token-prefix: Bearer

此时 UAAC 便可在读取 Token 时裁剪掉 Bearer,成功获取xxxx-xxxx-xxxx-xxxx

注意点
  1. Token前缀 与 Token值 之间必须有一个空格
  2. 一旦配置了 Token前缀,则前端提交token时,必须带有前缀,否则会导致框架无法读取token
  3. 由于Cookie中无法存储空格字符,也就意味配置token前缀后,Cookie鉴权方式将会失效,此时只能将token提交到header里进行传输

记住我模式


如图所示,一般网站的登录界面都会有一个 [记住我] 按钮,当你勾选它后,即使你关闭浏览器再次打开网站,也依然会处于登录状态,无须重复验证密码

那么在UAAC中,如何做到 [ 记住我 ] 功能呢?

在uaac中实现记住我功能

UAAC的登录授权,默认就是[记住我]模式,为了实现[非记住我]模式, 你需要在登录时如下设置:

java
// 设置登录账号id为10001,第二个参数指定是否为[记住我],当此值为false后,关闭浏览器后再次打开需要重新登录
StpUtil.login(10001, false);

那么,UAAC实现[记住我]的具体原理是?

实现原理

Cookie作为浏览器提供的默认会话跟踪机制,其生命周期有两种形式,分别是:

  • 临时Cookie:有效期为本次会话,只要关闭浏览器窗口,Cookie就会消失
  • 永久Cookie:有效期为一个具体的时间,在时间未到期之前,即使用户关闭了浏览器Cookie也不会消失

利用Cookie的此特性,我们便可以轻松实现 [记住我] 模式:

  • 勾选 [记住我] 按钮时:调用StpUtil.login(10001, true),在浏览器写入一个永久Cookie储存 Token,此时用户即使重启浏览器 Token 依然有效
  • 不勾选 [记住我] 按钮时:调用StpUtil.login(10001, false),在浏览器写入一个临时Cookie储存 Token,此时用户在重启浏览器后 Token 便会消失,导致会话失效
前后台分离模式下如何实现[记住我]?

此时机智的你😏很快发现一个问题,Cookie虽好,却无法在前后端分离环境下使用,那是不是代表上述方案在APP、小程序等环境中无效?

准确的讲,答案是肯定的,任何基于Cookie的认证方案在前后台分离环境下都会失效(原因在于这些客户端默认没有实现Cookie功能),不过好在,这些客户端一般都提供了替代方案, 唯一遗憾的是,此场景中token的生命周期需要我们在前端手动控制

以经典跨端框架 uni-app 为例,我们可以使用如下方式达到同样的效果:

js
// 使用本地存储保存token,达到 [永久Cookie] 的效果
uni.setStorageSync("uaac-token", "xxxx-xxxx-xxxx-xxxx-xxx");

// 使用globalData保存token,达到 [临时Cookie] 的效果
getApp().globalData.uaac-token = "xxxx-xxxx-xxxx-xxxx-xxx";

如果你决定在PC浏览器环境下进行前后台分离模式开发,那么更加简单:

js
// 使用 localStorage 保存token,达到 [永久Cookie] 的效果
localStorage.setItem("uaac-token", "xxxx-xxxx-xxxx-xxxx-xxx");

// 使用 sessionStorage 保存token,达到 [临时Cookie] 的效果
sessionStorage.setItem("uaac-token", "xxxx-xxxx-xxxx-xxxx-xxx");

Remember me, it's too easy!

登录时指定token有效期

登录时不仅可以指定是否为[记住我]模式,还可以指定一个特定的时间作为token有效时长,如下示例:

java
// 示例1:
// 指定token有效期(单位: 秒),如下所示token七天有效
StpUtil.login(10001, new CpLoginModel().setTimeout(60 * 60 * 24 * 7));

// ----------------------- 示例2:所有参数
// `CpLoginModel`为登录参数Model,其有诸多参数决定登录时的各种逻辑,例如:
StpUtil.login(10001, new CpLoginModel()
			.setDevice("PC")				// 此次登录的客户端设备标识, 用于[同端互斥登录]时指定此次登录的设备名称
			.setIsLastingCookie(true)		// 是否为持久Cookie(临时Cookie在浏览器关闭时会自动删除,持久Cookie在重新打开后依然存在)
			.setTimeout(60 * 60 * 24 * 7)	// 指定此次登录token的有效期, 单位:秒 (如未指定,自动取全局配置的timeout值)
			);

模拟他人 & 身份切换


以上介绍的api都是操作当前账号,对当前账号进行各种鉴权操作,你可能会问,我能不能对别的账号进行一些操作? 比如:查看账号10001有无某个权限码、获取 账号id=10002 的 User-Session,等等...

UAAC在api设计时充分考虑了这一点,暴露出多个api进行此类操作

有关操作其它账号的api

java
// 获取指定账号10001的`tokenValue`值 
StpUtil.getTokenValueByLoginId(10001);

// 将账号10001的会话注销登录
StpUtil.logout(10001);

// 获取账号10001的Session对象, 如果session尚未创建, 则新建并返回
StpUtil.getSessionByLoginId(10001);

// 获取账号10001的Session对象, 如果session尚未创建, 则返回null 
StpUtil.getSessionByLoginId(10001, false);

// 获取账号10001是否含有指定角色标识 
StpUtil.hasRole(10001, "super-admin");

// 获取账号10001是否含有指定权限码
StpUtil.hasPermission(10001, "user:add");

临时身份切换

有时候,我们需要直接将当前会话的身份切换为其它账号,比如:

java
// 将当前会话[身份临时切换]为其它账号 
StpUtil.switchTo(10044);

// 此时再调用此方法会返回 10044 (我们临时切换到的账号id)
StpUtil.getLoginId();

// 结束 [身份临时切换]
StpUtil.endSwitch();

你还可以: 直接在一个代码段里方法内,临时切换身份为指定loginId(此方式无需手动调用StpUtil.endSwitch()关闭身份切换)

java
System.out.println("------- [身份临时切换]调用开始...");
StpUtil.switchTo(10044, () -> {
	System.out.println("是否正在身份临时切换中: " + StpUtil.isSwitch()); 
	System.out.println("获取当前登录账号id: " + StpUtil.getLoginId());
});
System.out.println("------- [身份临时切换]调用结束...");

同端互斥登录

如果你经常使用腾讯QQ,就会发现它的登录有如下特点:它可以手机电脑同时在线,但是不能在两个手机上同时登录一个账号 同端互斥登录,指的就是像腾讯QQ一样,在同一类型设备上只允许单地点登录,在不同类型设备上允许同时在线


具体API

在 UAAC 中如何做到同端互斥登录? 首先在配置文件中,将 isConcurrent 配置为false,然后调用登录等相关接口时声明设备标识即可:

指定设备标识登录
java
// 指定`账号id`和`设备标识`进行登录
StpUtil.login(10001, "PC");	

调用此方法登录后,同设备的会被顶下线(不同设备不受影响),再次访问系统时会抛出 NotLoginException 异常,场景值=-4

指定设备标识强制注销
java
// 指定`账号id`和`设备标识`进行强制注销 
StpUtil.logout(10001, "PC");	

如果第二个参数填写null或不填,代表将这个账号id所有在线端强制注销,被踢出者再次访问系统时会抛出 NotLoginException 异常,场景值=-2

查询当前登录的设备标识
java
// 返回当前token的登录设备
StpUtil.getLoginDevice();	
Id 反查 Token
java
// 获取指定loginId指定设备端的tokenValue 
StpUtil.getTokenValueByLoginId(10001, "APP");	

二级认证

在某些敏感操作下,我们需要对已登录的会话进行二次验证

比如代码托管平台的仓库删除操作,尽管我们已经登录了账号,当我们点击 [删除] 按钮时,还是需要再次输入一遍密码,这么做主要为了两点:

  1. 保证操作者是当前账号本人
  2. 增加操作步骤,防止误删除重要数据

这就是我们本篇要讲的 —— 二级认证,即:在已登录会话的基础上,进行再次验证,提高会话的安全性。


具体API

UAAC中进行二级认证非常简单,只需要使用以下API:

java
// 在当前会话 开启二级认证,时间为120秒
StpUtil.openSafe(120); 

// 获取:当前会话是否处于二级认证时间内
StpUtil.isSafe(); 

// 检查当前会话是否已通过二级认证,如未通过则抛出异常
StpUtil.checkSafe(); 

// 获取当前会话的二级认证剩余有效时间 (单位: 秒, 返回-2代表尚未通过二级认证)
StpUtil.getSafeTime(); 

// 在当前会话 结束二级认证
StpUtil.closeSafe(); 

使用注解进行二级认证

在一个方法上使用 @CpCheckSafe 注解,可以在代码进入之前此方法之前进行一次二级认证

java
// 二级认证:必须二级认证之后才能进入该方法 
@CpCheckSafe      
@RequestMapping("add")
public String add() {
    return "用户增加";
}

详细使用方法可参考<注解鉴权>,此处不再赘述

Http Basic 认证

Http Basic 是 http 协议中最基础的认证方式,其有两个特点:

  • 简单、易集成。
  • 功能支持度低。

在 UAAC 中使用 Http Basic 认证非常简单,只需调用几个简单的方法


1、启用 Http Basic 认证

首先我们在一个接口中,调用 Http Basic 校验:

java
@RequestMapping("test3")
public CpResult test3() {
	CpBasicUtil.check("uaac:123456");
	return CpResult.ok();
}

然后我们访问这个接口时,浏览器会强制弹出一个表单:

当我们输入账号密码后 (uaac / 123456),才可以继续访问数据:

2、其它启用方式
java
// 对当前会话进行 Basic 校验,账号密码为 yml 配置的值(例如:uaac.basic=uaac:123456)
CpBasicUtil.check();

// 对当前会话进行 Basic 校验,账号密码为:`uaac / 123456`
CpBasicUtil.check("uaac:123456");

// 以注解方式启用 Basic 校验
@CpCheckBasic(account = "uaac:123456")
@RequestMapping("test3")
public CpResult test3() {
	return CpResult.ok();
}

// 在全局拦截器 或 过滤器中启用 Basic 认证 
@Bean
public CpServletFilter getCpServletFilter() {
	return new CpServletFilter()
			.addInclude("/**").addExclude("/favicon.ico")
			.setAuth(obj -> {
				CpRouter.match("/test/**", () -> CpBasicUtil.check("uaac:123456"));
			});
}
3、URL 认证

除了访问后再输入账号密码外,我们还可以在 URL 中直接拼接账号密码通过 Basic 认证,例如:

url
http://uaac:123456@127.0.0.1:8081/test/test3

密码加密

严格来讲,密码加密不属于 [权限认证] 的范畴,但是对于大多数系统来讲,密码加密又是安全认证不可或缺的部分, UAAC的密码加密模块非常简单,仅仅封装了一些常见的加密算法

摘要加密

md5、sha1、sha256

java
// md5加密 
CpSecureUtil.md5("123456");

// sha1加密 
CpSecureUtil.sha1("123456");

// sha256加密 
CpSecureUtil.sha256("123456");

// md5加盐加密: md5(md5(str) + md5(salt)) 
CpSecureUtil.md5BySalt("123456", "salt");
对称加密

AES加密

java
// 定义秘钥和明文
String key = "123456";
String text = "UAAC 一个轻量级java权限认证框架";

// 加密 
String ciphertext = CpSecureUtil.aesEncrypt(key, text);
System.out.println("AES加密后:" + ciphertext);

// 解密 
String text2 = CpSecureUtil.aesDecrypt(key, ciphertext);
System.out.println("AES解密后:" + text2);
非对称加密

RSA加密

java
// 定义私钥和公钥 
String privateKey = "MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAO+wmt01pwm9lHMdq7A8gkEigk0XKMfjv+4IjAFhWCSiTeP7dtlnceFJbkWxvbc7Qo3fCOpwmfcskwUc3VSgyiJkNJDs9ivPbvlt8IU2bZ+PBDxYxSCJFrgouVOpAr8ar/b6gNuYTi1vt3FkGtSjACFb002/68RKUTye8/tdcVilAgMBAAECgYA1COmrSqTUJeuD8Su9ChZ0HROhxR8T45PjMmbwIz7ilDsR1+E7R4VOKPZKW4Kz2VvnklMhtJqMs4MwXWunvxAaUFzQTTg2Fu/WU8Y9ha14OaWZABfChMZlpkmpJW9arKmI22ZuxCEsFGxghTiJQ3tK8npj5IZq5vk+6mFHQ6aJAQJBAPghz91Dpuj+0bOUfOUmzi22obWCBncAD/0CqCLnJlpfOoa9bOcXSusGuSPuKy5KiGyblHMgKI6bq7gcM2DWrGUCQQD3SkOcmia2s/6i7DUEzMKaB0bkkX4Ela/xrfV+A3GzTPv9bIBamu0VIHznuiZbeNeyw7sVo4/GTItq/zn2QJdBAkEA8xHsVoyXTVeShaDIWJKTFyT5dJ1TR++/udKIcuiNIap34tZdgGPI+EM1yoTduBM7YWlnGwA9urW0mj7F9e9WIQJAFjxqSfmeg40512KP/ed/lCQVXtYqU7U2BfBTg8pBfhLtEcOg4wTNTroGITwe2NjL5HovJ2n2sqkNXEio6Ji0QQJAFLW1Kt80qypMqot+mHhS+0KfdOpaKeMWMSR4Ij5VfE63WzETEeWAMQESxzhavN1WOTb3/p6icgcVbgPQBaWhGg==";
String publicKey = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDvsJrdNacJvZRzHauwPIJBIoJNFyjH47/uCIwBYVgkok3j+3bZZ3HhSW5Fsb23O0KN3wjqcJn3LJMFHN1UoMoiZDSQ7PYrz275bfCFNm2fjwQ8WMUgiRa4KLlTqQK/Gq/2+oDbmE4tb7dxZBrUowAhW9NNv+vESlE8nvP7XXFYpQIDAQAB";

// 文本
String text = "UAAC 一个轻量级java权限认证框架";

// 使用公钥加密
String ciphertext = CpSecureUtil.rsaEncryptByPublic(publicKey, text);
System.out.println("公钥加密后:" + ciphertext);

// 使用私钥解密
String text2 = CpSecureUtil.rsaDecryptByPrivate(privateKey, ciphertext);
System.out.println("私钥解密后:" + text2); 

你可能会有疑问,私钥和公钥这么长的一大串,我怎么弄出来,手写吗?当然不是,调用以下方法生成即可

java
// 生成一对公钥和私钥,其中Map对象 (private=私钥, public=公钥)
System.out.println(CpSecureUtil.rsaGenerateKeyPair());
Base64编码与解码
java
// 文本
String text = "UAAC 一个轻量级java权限认证框架";

// 使用Base64编码
String base64Text = CpBase64Util.encode(text);
System.out.println("Base64编码后:" + base64Text);

// 使用Base64解码
String text2 = CpBase64Util.decode(base64Text);
System.out.println("Base64解码后:" + text2); 

会话治理

尽管框架将大部分操作提供了简易的封装,但在一些特殊场景下,我们仍需要绕过框架,直达数据底层进行一些操作 UAAC提供以下API助你直接操作会话列表


具体API

java
// 查询所有token
StpUtil.searchTokenValue(String keyword, int start, int size);

// 查询所有账号Session会话
StpUtil.searchSessionId(String keyword, int start, int size);

// 查询所有令牌Session会话
StpUtil.searchTokenSessionId(String keyword, int start, int size);
参数详解:
  • keyword: 查询关键字,只有包括这个字符串的token值才会被查询出来
  • start: 数据开始处索引, 值为-1时代表一次性取出所有数据
  • size: 要获取的数据条数

使用示例:

java
// 查询value包括1000的所有token,结果集从第0条开始,返回10条
List<String> tokenList = StpUtil.searchTokenValue("1000", 0, 10);	
for (String token : tokenList) {
	System.out.println(token);
}
注意事项:

由于会话查询底层采用了遍历方式获取数据,当数据量过大时此操作将会比较耗时,有多耗时呢?这里提供一份参考数据:

  • 单机模式下:百万会话取出10条token平均耗时 0.255s
  • Redis模式下:百万会话取出10条token平均耗时 3.322s

请根据业务实际水平合理调用API

如果需要实时获取当前登录人数或者需要在用户退出后自动触发某事件等, 建议采用websocket技术

全局侦听器

接口CpListener是UAAC的全局侦听器,通过实现此接口,你可以在用户登陆、退出、被踢下线等关键性操作时进行一些AOP操作

框架对此侦听器的默认实现是log日志输出,你可以通过配置uaac.is-log=true开启

下面我们演示一下如何自定义侦听器的实现:


自定义侦听器实现

新建MyCpListener.java,继承CpListener接口,并添加上注解@Component,保证此类被SpringBoot扫描到

java
/**
 * 自定义侦听器的实现 
 */
@Component
public class MyCpListener implements CpListener {

	/** 每次登录时触发 */
	@Override
	public void doLogin(String loginType, Object loginId, SaLoginModel loginModel) {
		// ... 
	}

	/** 每次注销时触发 */
	@Override
	public void doLogout(String loginType, Object loginId, String tokenValue) {
		// ... 
	}

	/** 每次被踢下线时触发 */
	@Override
	public void doKickout(String loginType, Object loginId, String tokenValue) {
		// ... 
	}

	/** 每次被顶下线时触发 */
	@Override
	public void doReplaced(String loginType, Object loginId, String tokenValue) {
		// ... 
	}

	/** 每次被封禁时触发 */
	@Override
	public void doDisable(String loginType, Object loginId, long disableTime) {
		// ... 
	}

	/** 每次被解封时触发 */
	@Override
	public void doUntieDisable(String loginType, Object loginId) {
		// ... 
	}

	/** 每次创建Session时触发 */
	@Override
	public void doCreateSession(String id) {
		// ... 
	}

	/** 每次注销Session时触发 */
	@Override
	public void doLogoutSession(String id) {
		// ... 
	}

}

全局过滤器


组件简述

之前的章节中,我们学习了“根据拦截器实现路由拦截鉴权”,其实在大多数web框架中,使用过滤器可以实现同样的功能,本章我们就利用UAAC全局过滤器来实现路由拦截器鉴权。

首先我们先梳理清楚一个问题,既然拦截器已经可以实现路由鉴权,为什么还要用过滤器再实现一遍呢?简而言之:

  1. 相比于拦截器,过滤器更加底层,执行时机更靠前,有利于防渗透扫描
  2. 过滤器可以拦截静态资源,方便我们做一些权限控制
  3. 部分Web框架根本就没有提供拦截器功能,但几乎所有的Web框架都会提供过滤器机制

但是过滤器也有一些缺点,比如:

  1. 由于太过底层,导致无法率先拿到HandlerMethod对象,无法据此添加一些额外功能
  2. 由于拦截的太全面了,导致我们需要对很多特殊路由(如/favicon.ico)做一些额外处理
  3. 在Spring中,过滤器中抛出的异常无法进入全局@ExceptionHandler,我们必须额外编写代码进行异常处理

UAAC同时提供过滤器和拦截器机制,不是为了让谁替代谁,而是为了让大家根据自己的实际业务合理选择,拥有更多的发挥空间。

在 SpringBoot 中注册过滤器

同拦截器一样,为了避免不必要的性能浪费,UAAC全局过滤器默认处于关闭状态,若要使用过滤器组件,首先你需要注册它到项目中:

java
/**
 * [UAAC 权限认证] 配置类 
 */
@Configuration
public class CpTokenConfigure {
	
	/**
	 * 注册 [UAAC全局过滤器] 
	 */
	@Bean
	public CpServletFilter getCpServletFilter() {
        return new CpServletFilter()
		
        		// 指定 拦截路由 与 放行路由
        		.addInclude("/**").addExclude("/favicon.ico")
				
        		// 认证函数: 每次请求执行 
        		.setAuth(obj -> {
					System.out.println("---------- 进入UAAC全局认证 -----------");
					
					// 登录认证 -- 拦截所有路由,并排除/user/doLogin 用于开放登录 
					CpRouter.match("/**", "/user/doLogin", () -> StpUtil.checkLogin());
					
					// 更多拦截处理方式,请参考“路由拦截式鉴权”章节 
        		})
				
        		// 异常处理函数:每次认证函数发生异常时执行此函数 
        		.setError(e -> {
					System.out.println("---------- 进入UAAC异常处理 -----------");
        			return AjaxJson.getError(e.getMessage());
        		})
				
        		// 前置函数:在每次认证函数之前执行
        		.setBeforeAuth(r -> {
        			// ---------- 设置一些安全响应头 ----------
        			CpHolder.getResponse()
        			// 服务器名称 
        			.setServer("uaac-server")
        			// 是否可以在iframe显示视图: DENY=不可以 | SAMEORIGIN=同域下可以 | ALLOW-FROM uri=指定域名下可以 
        			.setHeader("X-Frame-Options", "SAMEORIGIN")
        			// 是否启用浏览器默认XSS防护: 0=禁用 | 1=启用 | 1; mode=block 启用, 并在检查到XSS攻击时,停止渲染页面 
        			.setHeader("X-XSS-Protection", "1; mode=block")
        			// 禁用浏览器内容嗅探 
        			.setHeader("X-Content-Type-Options", "nosniff")
        			;
        		})
        		;
	}
	
}
注意事项:
  • [认证函数]里,你可以写和拦截器里一致的代码,进行路由匹配鉴权,参考<路由拦截鉴权>
  • 由于过滤器中抛出的异常不进入全局异常处理,所以你必须提供[异常处理函数]来处理[认证函数]里抛出的异常
  • [异常处理函数]里的返回值,将作为字符串输出到前端,如果需要定制化返回数据,请注意其中的格式转换
在 WebFlux 中注册过滤器

Spring WebFlux中不提供拦截器机制,因此若你的项目需要路由鉴权功能,过滤器是你唯一的选择,在Spring WebFlux注册过滤器的流程与上述流程几乎完全一致, 除了您需要将过滤器名称由CpServletFilter更换为CpReactorFilter以外,其它所有步骤均可参考以上示例

java
/**
 * [UAAC 权限认证] 配置类 
 */
@Configuration
public class CpTokenConfigure {
		
	/**
	 * 注册 [UAAC全局过滤器] 
	 */
	@Bean
	public CpReactorFilter getCpReactorFilter() {
		return new CpReactorFilter()
			// 其它代码... 
		;
	}
	
}

多账号认证


0、需求场景

有的时候,我们会在一个项目中设计两套账号体系,比如一个电商系统的 user表admin表, 在这种场景下,如果两套账号我们都使用 StpUtil 类的API进行登录鉴权,那么势必会发生逻辑冲突

在UAAC中,这个问题的模型叫做:多账号体系认证

要解决这个问题,我们必须有一个合理的机制将这两套账号的授权给区分开,让它们互不干扰才行

1、演进思路

假如说我们的 user表 和 admin表 都有一个 id=10001 的账号,它们对应的登录代码:StpUtil.login(10001) 是一样的, 那么问题来了:在StpUtil.getLoginId()获取到的账号id如何区分它是User用户,还是Admin用户?

你可能会想到为他们加一个固定前缀,比如StpUtil.login("User_" + 10001)StpUtil.login("Admin_" + 10001),这样确实是可以解决问题的, 但是同样的:你需要在StpUtil.getLoginId()时再裁剪掉相应的前缀才能获取真正的账号id,这样一增一减就让我们的代码变得无比啰嗦

那么,有没有从框架层面支持的,更优雅的解决方案呢?

2、解决方案

前面几篇介绍的api调用,都是经过 StpUtil 类的各种静态方法进行授权认证, 而如果我们深入它的源码StpUtil.java,就会发现,此类并没有任何代码逻辑,唯一做的事就是对成员变量stpLogic的各个API包装一下进行转发

这样做有两个优点:

  • StpLogic 类的所有函数都可以被重写,按需扩展
  • 在构造方法时随意传入一个不同的 loginType,就可以再造一套账号登录体系
3、操作示例

比如说,对于原生StpUtil类,我们只做admin账号权限认证,而对于user账号,我们则:

  1. 新建一个新的权限认证类,比如: StpUserUtil.java
  2. StpUtil.java类的全部代码复制粘贴到 StpUserUtil.java
  3. 更改一下其 LoginType, 比如:
java
public class StpUserUtil {
	
	/**
	 * 账号体系标识 
	 */
	public static final String TYPE = "user";	// 将 LoginType 从`login`改为`user` 

	// 其它代码 ... 

}
  1. 接下来就可以像调用StpUtil.java一样调用 StpUserUtil.java了,这两套账号认证的逻辑是完全隔离的

成品样例参考StpUserUtil.java

4、在多账号模式下使用注解鉴权

框架默认的注解鉴权 如@CpCheckLogin 只针对原生StpUtil进行鉴权

例如,我们在一个方法上加上@CpCheckLogin注解,这个注解只会放行通过StpUtil.login(id)进行登录的会话, 而对于通过StpUserUtil.login(id)进行登录的都会话,则始终不会通过校验

那么如何告诉@CpCheckLogin要鉴别的是哪套账号的登录会话呢?很简单,你只需要指定一下注解的type属性即可:

java
// 通过type属性指定此注解校验的是我们自定义的`StpUserUtil`,而不是原生`StpUtil`
@CpCheckLogin(type = StpUserUtil.TYPE)
@RequestMapping("info")
public String info() {
    return "查询用户信息";
}

注:@CpCheckRole("xxx")@CpCheckPermission("xxx")同理,亦可根据type属性指定其校验的账号体系,此属性默认为"",代表使用原生StpUtil账号体系

5、使用注解合并简化代码

交流群里有同学反应,虽然可以根据 @CpCheckLogin(type = "user") 指定账号类型,但几十上百个注解都加上这个的话,还是有些繁琐,代码也不够优雅,有么有更改的解决方案?

我们期待一种[注解继承/合并]的能力,即:自定义一个注解,标注上@CpCheckLogin(type = "user"),然后在方法上标注这个自定义注解,效果等同于标注@CpCheckLogin(type = "user")

很遗憾,JDK默认的注解处理器并没有提供这种[注解继承/合并]的能力,不过好在我们可以利用 Spring 的注解处理器,达到同样的目的

  1. 重写UAAC默认的注解处理器
java
@Configuration
public class CpTokenConfigure {
    @Autowired
    public void rewriteCpStrategy() {
    	// 重写UAAC的注解处理器,增加注解合并功能 
		CpStrategy.me.getAnnotation = (element, annotationClass) -> {
			return AnnotatedElementUtils.getMergedAnnotation(element, annotationClass); 
		};
    }
}
  1. 自定义一个注解
java
/**
 * 登录认证(User版):只有登录之后才能进入该方法 
 * <p> 可标注在函数、类上(效果等同于标注在此类的所有方法上) 
 */
@CpCheckLogin(type = "user")
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.METHOD, ElementType.TYPE})
public @interface CpUserCheckLogin {
	
}
  1. 接下来就可以使用我们的自定义注解了
java
// 使用 @CpUserCheckLogin 的效果等同于使用:@CpCheckLogin(type = "user")
@CpUserCheckLogin
@RequestMapping("info")
public String info() {
    return "查询用户信息";
}

注:其它注解 @CpCheckRole("xxx")@CpCheckPermission("xxx")同理,完整示例见/uaac-demo/uaac-demo-springboot/src/main/java/com/cpit/uaac/at。

6、同端多登陆

假设我们不仅需要在后台同时集成两套账号,我们还需要在一个客户端同时登陆两套账号(业务场景举例:一个APP中可以同时登陆商家账号和用户账号)

如果我们不做任何特殊处理的话,在客户端会发生token覆盖,新登录的token会覆盖掉旧登录的token从而导致旧登录失效

那么如何解决这个问题? 很简单,我们只要更改一下 StpUserUtilTokenName 即可,参考示例如下:

java
public class StpUserUtil {
	
	// 使用匿名子类 重写`stpLogic对象`的一些方法 
	public static StpLogic stpLogic = new StpLogic("user") {
		// 重写 StpLogic 类下的 `splicingKeyTokenName` 函数,返回一个与 `StpUtil` 不同的token名称, 防止冲突 
		@Override
		public String splicingKeyTokenName() {
			return super.splicingKeyTokenName() + "-user";
		}
		// 同理你可以按需重写一些其它方法 ... 
	}; 
	
	// ... 
	
}

再次调用 StpUserUtil.login(10001) 进行登录授权时,token的名称将不再是 uaac-token,而是我们重写后的 uaac-token-user

7、不同体系不同 CpConfig 配置

如果自定义的 StpUserUtil 需要使用不同 CpConfig 对象, 也很简单,参考示例如下:

java
public class StpUserUtil {
	
	// 使用匿名子类 重写`stpLogic对象`的一些方法 
	public static StpLogic stpLogic = new StpLogic("user") {
		
		// 首先自定义一个 Config 对象 
		CpConfig config = new CpConfig()
			.setTokenName("uaac-token")
			.setTimeout(2592000)
			// ... 其它set
			;
		
		// 然后重写 stpLogic 配置获取方法 
		@Override
		public CpConfig getConfig() {
			return config;
		}
	};
	
	// ... 
	
}

单点登录

单点登录简述

凡是稍微上点规模的系统,统一认证中心都是绕不过去的槛。而单点登录——便是我们搭建统一认证中心的关键。


什么是单点登录?解决什么问题?

举个场景,假设我们的系统被切割为N个部分:商城、论坛、直播、社交…… 如果用户每访问一个模块都要登录一次,那么用户将会疯掉, 为了优化用户体验,我们急需一套机制将这N个系统的认证授权互通共享,让用户在一个系统登录之后,便可以畅通无阻的访问其它所有系统。

单点登录——就是为了解决这个问题而生!

简而言之,单点登录可以做到:在多个互相信任的系统中,用户只需登录一次,就可以访问所有系统。

架构选型

对于单点登录,网上教程大多以CAS模式为主,其实对于不同的系统架构,实现单点登录的步骤也大为不同,UAAC 由简入难将其划分为三种模式:

系统架构采用模式简介文档链接
前端同域 + 后端同 Redis模式一共享 Cookie 同步会话文档见<SSO模式一 共享Cookie同步会话>、示例见/uaac-demo/uaac-demo-sso1-client目录
前端不同域 + 后端同 Redis模式二URL重定向传播会话文档见<SSO模式二 URL重定向传播会话>、示例见/uaac-demo/uaac-demo-sso2-client目录
前端不同域 + 后端不同 Redis模式三Http请求获取会话文档见<SSO模式三 Http请求获取会话>、示例见/uaac-demo/uaac-demo-sso3-client目录
  1. 前端同域:就是指多个系统可以部署在同一个主域名之下,比如:c1.domain.comc2.domain.comc3.domain.com
  2. 后端同Redis:就是指多个系统可以连接同一个Redis,其它的缓存数据中心亦可。PS:这里并不需要把所有项目的数据都放在同一个Redis中,UAAC提供了 [权限缓存与业务缓存分离] 的解决方案,详情见<Alone独立Redis插件>。
  3. 如果既无法做到前端同域,也无法做到后端同Redis,那么只能走模式三,Http请求获取会话(UAAC对SSO提供了完整的封装,你只需要按照示例从文档上复制几段代码便可以轻松集成)。
  4. 技术选型一定要根据系统架构对症下药,切不可胡乱选择。
UAAC-SSO 特性
  1. API简单易用,文档介绍详细,且提供直接可用的集成示例
  2. 支持三种模式,不论是否跨域、是否共享Redis、是否前后端分离,都可以完美解决
  3. 安全性高:内置域名校验、Ticket校验、秘钥校验等,杜绝Ticket劫持Token窃取等常见攻击手段(文档讲述攻击原理和防御手段)
  4. 不丢参数:笔者曾试验多个单点登录框架,均有参数丢失的情况,比如重定向之前是:http://a.com?id=1&name=2,登录成功之后就变成了:http://a.com?id=1,UAAC-SSO内有专门的算法保证了参数不丢失,登录成功之后原路返回页面
  5. 无缝集成:由于UAAC本身就是一个权限认证框架,因此你可以只用一个框架同时解决权限认证 + 单点登录问题,让你不再到处搜索:xxx单点登录与xxx权限认证如何整合……
  6. 高可定制:UAAC-SSO模块对代码架构侵入性极低,结合UAAC本身的路由拦截特性,你可以非常轻松的定制化开发

搭建统一认证中心:SSO-Server

在开始SSO三种模式的对接之前,我们必须先搭建一个 SSO-Server 认证中心

搭建示例在官方仓库的 /uaac-demo/uaac-demo-sso-server/,如遇到难点可结合源码进行测试学习,demo里有制作好的登录页面


1、添加依赖

创建 SpringBoot 项目 uaac-demo-sso-server,引入依赖:

xml
<!-- UAAC 权限认证 -->
<dependency>
	<groupId>com.cpit.csc</groupId>
	<artifactId>uaac-spring-boot-starter</artifactId>
	<version>${uaac.top.version}</version>
</dependency>

<!-- UAAC 整合 Redis (使用 jackson 序列化方式) -->
<dependency>
	<groupId>com.cpit.csc</groupId>
	<artifactId>uaac-dao-redis-jackson</artifactId>
	<version>${uaac.top.version}</version>
</dependency>
<dependency>
	<groupId>org.apache.commons</groupId>
	<artifactId>commons-pool2</artifactId>
</dependency>

<!-- 视图引擎(在前后端不分离模式下提供视图支持) -->
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

<!-- Http请求工具(在模式三的单点注销功能下用到,如不需要可以注释掉) -->
<dependency>
	 <groupId>com.ejlchina</groupId>
	 <artifactId>okhttps</artifactId>
	 <version>3.1.1</version>
</dependency>

除了 uaac-spring-boot-starter 以外,其它包都是可选的:

  • 在SSO模式三时 Redis 相关包是可选的
  • 在前后端分离模式下可以删除 thymeleaf 相关包
  • 在不需要SSO模式三单点注销的情况下可以删除 http 工具包

建议先完整测试三种模式之后再对pom依赖进行酌情删减。

2、开放认证接口

新建 SsoServerController,用于对外开放接口:

java
/**
 * UAAC-SSO Server端 Controller 
 */
@RestController
public class SsoServerController {

	/*
	 * SSO-Server端:处理所有SSO相关请求 (下面的章节我们会详细列出开放的接口) 
	 */
	@RequestMapping("/sso/*")
	public Object ssoRequest() {
		return CpSsoHandle.serverRequest();
	}
	
	/**
	 * 配置SSO相关参数 
	 */
	@Autowired
	private void configSso(CpConfig cfg) {
		// 配置:未登录时返回的View 
		cfg.sso.setNotLoginView(() -> {
			String msg = "当前会话在SSO-Server端尚未登录,请先访问"
					+ "<a href='/sso/doLogin?name=uaac&pwd=123456' target='_blank'> doLogin登录 </a>"
					+ "进行登录之后,刷新页面开始授权";
			return msg;
		});
		
		// 配置:登录处理函数 
		cfg.sso.setDoLoginHandle((name, pwd) -> {
			// 此处仅做模拟登录,真实环境应该查询数据进行登录 
			if("uaac".equals(name) && "123456".equals(pwd)) {
				StpUtil.login(10001);
				return CpResult.ok("登录成功!").setData(StpUtil.getTokenValue());
			}
			return CpResult.error("登录失败!");
		});
		
		// 配置 Http 请求处理器 (在模式三的单点注销功能下用到,如不需要可以注释掉) 
		cfg.sso.setSendHttp(url -> {
			return OkHttps.sync(url).get().getBody().toString();
		});
	}
	
}

注:在setDoLoginHandle函数里如果要获取name, pwd以外的参数,可通过CpHolder.getRequest().getParam("xxx")来获取

全局异常处理:

java
@RestControllerAdvice
public class GlobalExceptionHandler {
	// 全局异常拦截 
	@ExceptionHandler
	public CpResult handlerException(Exception e) {
		e.printStackTrace(); 
		return CpResult.error(e.getMessage());
	}
}
3、application.yml配置
yml
### 端口
server:
    port: 9000

### UAAC 配置
uaac: 
    # -------------- SSO-模式一相关配置  (非模式一不需要配置) 
    # cookie: 
        # 配置Cookie作用域 
        # domain: stp.com 
        
    # ------- SSO-模式二相关配置 
    sso: 
        # Ticket有效期 (单位: 秒),默认五分钟 
        ticket-timeout: 300
        # 所有允许的授权回调地址
        allow-url: "*"
        # 是否打开单点注销功能
        is-slo: true
        
        # ------- SSO-模式三相关配置 (下面的配置在SSO模式三并且 is-slo=true 时打开) -------
        # 是否打开模式三 
        isHttp: true
        # 接口调用秘钥(用于SSO模式三的单点注销功能)
        secretkey: kQwIOrYvnXmSDkwEiFngrKidMcdrgKor
        # ---- 除了以上配置项,你还需要为 UAAC 配置http请求处理器(文档有步骤说明) 
        
spring: 
    # Redis配置 (SSO模式一和模式二使用Redis来同步会话)
    redis:
        # Redis数据库索引(默认为0)
        database: 1
        # Redis服务器地址
        host: 127.0.0.1
        # Redis服务器连接端口
        port: 6379
        # Redis服务器连接密码(默认为空)
        password: 

注意点:allow-url为了方便测试配置为*,线上生产环境一定要配置为详细URL地址 (之后的章节我们会详细阐述此配置项)

4、创建启动类
java
@SpringBootApplication
public class UaacSsoServerApplication {
	public static void main(String[] args) {
		SpringApplication.run(UaacSsoServerApplication.class, args);
		System.out.println("\n------ UAAC-SSO 认证中心启动成功");
	}
}

启动项目

访问统一授权地址:

可以看到这个页面目前非常简陋,这是因为我们以上的代码示例,主要目标是为了带大家从零搭建一个可用的SSO认证服务端,所以就对一些不太必要的步骤做了简化。

大家可以下载运行一下官方示例/uaac-demo/uaac-demo-sso-server/,里面有制作好的登录页面:

默认账号密码为:uaac / 123456,先别着急点击登录,因为我们还没有搭建对应的 Client 端项目, 真实项目中我们是不会直接从浏览器访问 /sso/auth 授权地址的,我们需要在 Client 端点击登录按钮重定向而来。

现在我们先来看看除了 /sso/auth 统一授权地址,这个 SSO-Server 认证中心还开放了哪些API。

5、API 列表

如果你仅仅使用 UAAC 搭建 SSO-Server 端,而 Client 端使用其它框架的话,那么下面的 API 列表将给你的对接步骤做一份参考。

如果你在 Client 端也用到了 UAAC 框架,那么你可以选择跳过本小节,UAAC 对 Client 端也提供了相应的封装,你可以直接开始学习:<SSO模式一 共享Cookie同步会话>

5.1、单点登录授权地址
url
http://{host}:{port}/sso/auth

接收参数:

参数是否必填说明
redirect登录成功后的重定向地址,一般填写 location.href(从哪来回哪去)
mode授权模式,取值 [simple, ticket],simple=登录后直接重定向,ticket=带着ticket参数重定向,默认值为ticket

访问接口后有两种情况:

  • 情况一:当前会话在 SSO 认证中心未登录,会进入登录页开始登录。
  • 情况二:当前会话在 SSO 认证中心已登录,会被重定向至 redirect 地址,并携带 ticket 参数。
5.2、RestAPI 登录接口
url
http://{host}:{port}/sso/doLogin

接收参数:

参数是否必填说明
name用户名
pwd密码

此接口属于 RestAPI (使用ajax访问),会进入后端配置的 setDoLoginHandle 函数中,另外需要注意: 此接口并非只能携带 name、pwd 参数,因为你可以在 setDoLoginHandle 函数里通过 CpHolder.getRequest().getParam("xxx") 来获取其它参数。

5.3、Ticket 校验接口

此接口仅配置模式三 (isHttp=true) 时打开

url
http://{host}:{port}/sso/checkTicket

接收参数:

参数是否必填说明
ticket在步骤 5.1 中授权重定向时的 ticket 参数
ssoLogoutCall单点注销时的回调通知地址,只在SSO模式三单点注销时需要携带此参数

返回值场景:

  • 返回空,代表校验失败。
  • 返回具体的 loginId,例如10001,代表校验成功,值为此 ticket 码代表的用户id。
5.4、单点注销接口
url
http://{host}:{port}/sso/logout         

接受参数:

参数是否必填说明
loginId要注销的账号id
secretkey接口通信秘钥
back注销成功后的重定向地址

此接口有两种调用方式

####### 方式一:在 Client 的前端页面引导用户直接跳转,并带有 back 参数 例如:http://{host}:{port}/sso/logout?back=xxx,代表用户注销成功后返回back地址

####### 方式二:在 Client 的后端通过 http 工具来调用 例如:http://{host}:{port}/sso/logout?loginId={value}&secretkey={value},代表注销 账号=loginId 的账号,返回json数据结果,形如:

js
{
    "code": 200,    // 200表示请求成功,非200标识请求失败
    "msg": "单点注销成功",
    "data": null
}

SSO 认证中心只有这四个接口,接下来让我一起来看一下 Client 端的对接流程:SSO模式一 共享Cookie同步会话

SSO模式一 共享Cookie同步会话

如果我们的多个系统可以做到:前端同域、后端同Redis,那么便可以使用 [共享Cookie同步会话] 的方式做到单点登录。


1、解决思路?

首先我们分析一下多个系统之间,为什么无法同步登录状态?

  1. 前端的 Token 无法在多个系统下共享。
  2. 后端的 Session 无法在多个系统间共享。

所以单点登录第一招,就是对症下药:

  1. 使用 共享Cookie 来解决 Token 共享问题。
  2. 使用 Redis 来解决 Session 共享问题。

所谓共享Cookie,就是主域名Cookie在二级域名下的共享,举个例子:写在父域名stp.com下的Cookie,在s1.stp.coms2.stp.com等子域名都是可以共享访问的。

而共享Redis,并不需要我们把所有项目的数据都放在同一个Redis中,UAAC提供了 [权限缓存与业务缓存分离] 的解决方案,详情见<Alone独立Redis插件>。

OK,所有理论就绪,下面开始实战:

2、准备工作

首先修改hosts文件(C:\windows\system32\drivers\etc\hosts),添加以下IP映射,方便我们进行测试:

url
127.0.0.1 sso.stp.com
127.0.0.1 s1.stp.com
127.0.0.1 s2.stp.com
127.0.0.1 s3.stp.com

其中:sso.stp.com为统一认证中心地址,当用户在其它 Client 端发起登录请求时,均将其重定向至认证中心,待到登录成功之后再原路返回到 Client 端。

3、指定Cookie的作用域

sso.stp.com访问服务器,其Cookie也只能写入到sso.stp.com下,为了将Cookie写入到其父级域名stp.com下,我们需要更改 SSO-Server 端的 yml 配置:

yml
uaac:
    cookie:
        # 配置Cookie作用域 
        domain: stp.com

这个配置原本是被注释掉的,现在将其打开。另外我们格外需要注意: 在SSO模式一测试完毕之后,一定要将这个配置再次注释掉,因为模式一与模式二三使用不同的授权流程,这行配置会影响到我们模式二和模式三的正常运行。

4、搭建 Client 端项目

搭建示例在官方仓库的 /uaac-demo/uaac-demo-sso1-client/,如遇到难点可结合源码进行测试学习。

4.1、引入依赖

新建项目 uaac-demo-sso1-client,并添加以下依赖:

xml
<!-- UAAC 权限认证 -->
<dependency>
	<groupId>com.cpit.csc</groupId>
	<artifactId>uaac-spring-boot-starter</artifactId>
	<version>${uaac-version}</version>
</dependency>

<!-- UAAC 整合redis (使用jackson序列化方式) -->
<dependency>
	<groupId>com.cpit.csc</groupId>
	<artifactId>uaac-dao-redis-jackson</artifactId>
	<version>${uaac-version}</version>
</dependency>
<dependency>
	<groupId>org.apache.commons</groupId>
	<artifactId>commons-pool2</artifactId>
</dependency>

<!-- UAAC插件:权限缓存与业务缓存分离 -->
<dependency>
	<groupId>com.cpit.csc</groupId>
	<artifactId>uaac-alone-redis</artifactId>
	<version>${uaac-version}</version>
</dependency>
4.2、新建 Controller 控制器
java
/**
 * UAAC-SSO Client端 Controller 
 */
@RestController
public class SsoClientController {

	// SSO-Client端:首页 
	@RequestMapping("/")
	public String index() {
		String authUrl = CpManager.getConfig().getSso().getAuthUrl();
		String solUrl = CpManager.getConfig().getSso().getSloUrl();
		String str = "<h2>UAAC SSO-Client 应用端</h2>" + 
					"<p>当前会话是否登录:" + StpUtil.isLogin() + "</p>" + 
					"<p><a href=\"javascript:location.href='" + authUrl + "?mode=simple&redirect=' + encodeURIComponent(location.href);\">登录</a> " + 
					"<a href=\"javascript:location.href='" + solUrl + "?back=' + encodeURIComponent(location.href);\">注销</a> </p>";
		return str;
	}
	
	// 全局异常拦截 
	@ExceptionHandler
	public CpResult handlerException(Exception e) {
		e.printStackTrace(); 
		return CpResult.error(e.getMessage());
	}
	
}
4.3、application.yml 配置
yml
### 端口
server:
    port: 9001

### uaac配置 
uaac: 
    # SSO-相关配置
    sso: 
        # SSO-Server端-单点登录授权地址 
        auth-url: http://sso.stp.com:9000/sso/auth
        # SSO-Server端-单点注销地址
        slo-url: http://sso.stp.com:9000/sso/logout
    
    # 配置UAAC单独使用的Redis连接 (此处需要和SSO-Server端连接同一个Redis)
    alone-redis: 
        # Redis数据库索引
        database: 1
        # Redis服务器地址
        host: 127.0.0.1
        # Redis服务器连接端口
        port: 6379
        # Redis服务器连接密码(默认为空)
        password: 
        # 连接超时时间
        timeout: 10s
4.4、启动类
java
/**
 * SSO模式一,Client端 Demo 
 */
@SpringBootApplication
public class UaacSsoClientApplication {
	public static void main(String[] args) {
		SpringApplication.run(UaacSsoClientApplication.class, args);
		System.out.println("\nUAAC SSO模式一 Client端启动成功");
	}
}
5、访问测试

启动项目,依次访问三个应用端:

然后点击登录,被重定向至SSO认证中心:

我们点击登录,然后刷新页面,刷新另外两个Client端,均显示已登录

测试完成

6、跨域模式下的解决方案

如上,我们使用简单的步骤实现了同域下的单点登录,聪明如你😏,马上想到了这种模式有着一个不小的限制:

所有子系统的域名,必须同属一个父级域名

如果我们的子系统在完全不同的域名下,我们又该怎么完成单点登录功能呢?

且往下看,SSO模式二:URL重定向传播会话

SSO模式二 URL重定向传播会话

如果我们的多个系统:部署在不同的域名之下,但是后端可以连接同一个Redis,那么便可以使用 [URL重定向传播会话] 的方式做到单点登录。

1、解题思路

首先我们再次复习一下,多个系统之间为什么无法同步登录状态?

  1. 前端的Token无法在多个系统下共享。
  2. 后端的Session无法在多个系统间共享。

关于第二点,我们已在 "SSO模式一" 章节中阐述,使用 <Alone独立Redis插件> 做到权限缓存直连 SSO-Redis 数据中心,在此不再赘述。

而第一点,才是我们解决问题的关键所在,在跨域模式下,意味着 "共享Cookie方案" 的失效,我们必须采用一种新的方案来传递Token。

  1. 用户在 子系统 点击 [登录] 按钮。
  2. 用户跳转到子系统登录接口 /sso/login,并携带 back参数 记录初始页面URL。
  • 形如:http://{sso-client}/sso/login?back=xxx
  1. 子系统检测到此用户尚未登录,再次将其重定向至SSO认证中心,并携带redirect参数记录子系统的登录页URL。
  • 形如:http://{sso-server}/sso/auth?redirect=xxx?back=xxx
  1. 用户进入了 SSO认证中心 的登录页面,开始登录。
  2. 用户 输入账号密码 并 登录成功,SSO认证中心再次将用户重定向至子系统的登录接口/sso/login,并携带ticket码参数。
  • 形如:http://{sso-client}/sso/login?back=xxx&ticket=xxxxxxxxx
  1. 子系统根据 ticket码SSO-Redis 中获取账号id,并在子系统登录此账号会话。
  2. 子系统将用户再次重定向至最初始的 back 页面。

整个过程,除了第四步用户在SSO认证中心登录时会被打断,其余过程均是自动化的,当用户在另一个子系统再次点击[登录]按钮,由于此用户在SSO认证中心已有会话存在, 所以第四步也将自动化,也就是单点登录的最终目的 —— 一次登录,处处通行。

下面我们按照步骤依次完成上述过程:

2、准备工作

首先修改hosts文件(C:\windows\system32\drivers\etc\hosts),添加以下IP映射,方便我们进行测试:

url
127.0.0.1 uaac-sso-server.com
127.0.0.1 uaac-sso-client1.com
127.0.0.1 uaac-sso-client2.com
127.0.0.1 uaac-sso-client3.com
3、搭建 Client 端项目

搭建示例在官方仓库的 /uaac-demo/uaac-demo-sso2-client/,如遇到难点可结合源码进行测试学习

在SSO模式一章节中我们打开了配置:

yml
uaac: 
	cookie:
		# 配置Cookie作用域
		domain: stp.com

此为模式一专属配置,现在我们将其注释掉,并按照注释提示打开其他相应的注释

3.2、创建 SSO-Client 端项目

创建一个 SpringBoot 项目 uaac-demo-sso-client,引入依赖:

xml
<!-- UAAC 权限认证 -->
<dependency>
	<groupId>com.cpit.csc</groupId>
	<artifactId>uaac-spring-boot-starter</artifactId>
	<version>${uaac.top.version}</version>
</dependency>

<!-- UAAC 整合redis (使用jackson序列化方式) -->
<dependency>
	<groupId>com.cpit.csc</groupId>
	<artifactId>uaac-dao-redis-jackson</artifactId>
	<version>${uaac.top.version}</version>
</dependency>
<dependency>
	<groupId>org.apache.commons</groupId>
	<artifactId>commons-pool2</artifactId>
</dependency>

<!-- UAAC插件:权限缓存与业务缓存分离 -->
<dependency>
	<groupId>com.cpit.csc</groupId>
	<artifactId>uaac-alone-redis</artifactId>
	<version>${uaac.top.version}</version>
</dependency>
3.3、创建 SSO-Client 端认证接口

同 SSO-Server 一样,UAAC 为 SSO-Client 端所需代码也提供了完整的封装,你只需提供一个访问入口,接入 UAAC 的方法即可。

java

/**
 * UAAC-SSO Client端 Controller 
 */
@RestController
public class SsoClientController {

	// 首页 
	@RequestMapping("/")
	public String index() {
		String str = "<h2>UAAC SSO-Client 应用端</h2>" + 
					"<p>当前会话是否登录:" + StpUtil.isLogin() + "</p>" + 
					"<p><a href=\"javascript:location.href='/sso/login?back=' + encodeURIComponent(location.href);\">登录</a> " + 
					"<a href='/sso/logout?back=self'>注销</a></p>";
		return str;
	}
	
	/*
	 * SSO-Client端:处理所有SSO相关请求 
	 * 		http://{host}:{port}/sso/login          -- Client端登录地址,接受参数:back=登录后的跳转地址 
	 * 		http://{host}:{port}/sso/logout         -- Client端单点注销地址(isSlo=true时打开),接受参数:back=注销后的跳转地址 
	 * 		http://{host}:{port}/sso/logoutCall     -- Client端单点注销回调地址(isSlo=true时打开),此接口为框架回调,开发者无需关心
	 */
	@RequestMapping("/sso/*")
	public Object ssoRequest() {
		return CpSsoHandle.clientRequest();
	}

}
3.4、配置SSO认证中心地址

你需要在 application.yml 配置如下信息:

yml
### 端口
server:
    port: 9001
	
### uaac配置 
uaac: 
	# SSO-相关配置
	sso: 
		# SSO-Server端 统一认证地址 
		auth-url: http://uaac-sso-server.com:9000/sso/auth
        # 是否打开单点注销接口
        is-slo: true
	
	# 配置UAAC单独使用的Redis连接 (此处需要和SSO-Server端连接同一个Redis)
	alone-redis: 
		# Redis数据库索引 (默认为0)
		database: 1
		# Redis服务器地址
		host: 127.0.0.1
		# Redis服务器连接端口
		port: 6379
		# Redis服务器连接密码(默认为空)
		password: 

注意点:uaac.alone-redis 的配置需要和SSO-Server端连接同一个Redis(database也要一样)

3.5、写启动类
java
@SpringBootApplication
public class UaacSsoClientApplication {
	public static void main(String[] args) {
		SpringApplication.run(UaacSsoClientApplication.class, args);
		System.out.println("\nUAAC-SSO Client端启动成功");
	}
}

启动项目

4、测试访问

(1) 依次启动 SSO-ServerSSO-Client,然后从浏览器访问:http://uaac-sso-client1.com:9001/

(2) 首次打开,提示当前未登录,我们点击**登录** 按钮,页面会被重定向到登录中心

(3) SSO-Server提示我们在认证中心尚未登录,我们点击 **doLogin登录**按钮进行模拟登录

(4) SSO-Server认证中心登录成功,我们回到刚才的页面刷新页面

(5) 页面被重定向至Client端首页,并提示登录成功,至此,Client1应用已单点登录成功!

(6) 我们再次访问Client2http://uaac-sso-client2.com:9001/

(7) 提示未登录,我们点击**登录**按钮,会直接提示登录成功

(8) 同样的方式,我们打开Client3,也可以直接登录成功:http://uaac-sso-client3.com:9001/

至此,测试完毕!

可以看出,除了在Client1端我们需要手动登录一次之外,在Client2端Client3端都是可以无需再次认证,直接登录成功的。

5、跨 Redis 的单点登录

以上流程解决了跨域模式下的单点登录,但是后端仍然采用了共享Redis来同步会话,如果我们的架构设计中Client端与Server端无法共享Redis,又该怎么完成单点登录?

这就要采用模式三了,且往下看:SSO模式三:Http请求获取会话

SSO模式三 Http请求获取会话

如果既无法做到前端同域,也无法做到后端同Redis,那么可以使用模式三完成单点登录

阅读本篇之前请务必先熟读SSO模式二!因为模式三仅仅属于模式二的一个特殊场景,熟读模式二有助于您快速理解本章内容

1、问题分析

我们先来分析一下,当后端不使用共享 Redis 时,会对架构产生哪些影响:

  1. Client 端无法直连 Redis 校验 ticket,取出账号id。
  2. Client 端无法与 Server 端共用一套会话,需要自行维护子会话。
  3. 由于不是一套会话,所以无法“一次注销,全端下线”,需要额外编写代码完成单点注销。

所以模式三的主要目标:也就是在 模式二的基础上 解决上述 三个难题

模式三的 Demo 示例地址:/uaac-demo/uaac-demo-sso3-client/ 如遇难点可参考示例

2、在Client 端更改 Ticket 校验方式
2.1、增加 pom.xml 配置
xml
<!-- Http请求工具 -->
<dependency>
     <groupId>com.ejlchina</groupId>
     <artifactId>okhttps</artifactId>
     <version>3.1.1</version>
</dependency>

OkHttps是一个轻量级http请求工具,详情参考:OkHttps

2.2、配置 http 请求处理器

在SSO-Client端的 SsoClientController 中,新增以下配置

java
// 配置SSO相关参数 
@Autowired
private void configSso(CpConfig cfg) {
	// ... 其他代码
	
	// 配置 Http 请求处理器
	cfg.sso.setSendHttp(url -> {
		return OkHttps.sync(url).get().getBody().toString();
	});
}
2.3、application.yml 新增配置
yml
uaac: 
	sso: 
        # 打开模式三(使用Http请求校验ticket)
        is-http: true
		# SSO-Server端 ticket校验地址 
		check-ticket-url: http://uaac-sso-server.com:9000/sso/checkTicket
2.4、启动项目测试

重启项目,访问测试:http://uaac-sso-client1.com:9001/

注:如果已测试运行模式二,可先将Redis中的数据清空,以防旧数据对测试造成干扰

3、获取 Userinfo

除了账号id,我们可能还需要将用户的昵称、头像等信息从 Server端 带到 Client端,即:用户资料的同步。

在模式二中我们只需要将需要同步的资料放到 CpSession 即可,但是在模式三中两端不再连接同一个Redis,这时候我们需要通过http接口来同步信息:

3.1、在 Server 端自定义接口,查询用户资料
java
// 自定义接口:获取userinfo 
@RequestMapping("/sso/userinfo")
public Object userinfo(String loginId, String secretkey) {
	System.out.println("---------------- 获取userinfo --------");
	
	// 校验调用秘钥 
	CpSsoUtil.checkSecretkey(secretkey);
	
	// 自定义返回结果(模拟)
	return CpResult.ok()
			.set("id", loginId)
			.set("name", "linxiaoyu")
			.set("sex", "")
			.set("age", 18);
}
3.2、在 Client 端调用此接口查询 userinfo

首先在yml中配置接口地址

yml
uaac: 
    sso: 
        # SSO-Server端 查询userinfo地址 
        userinfo-url: http://uaac-sso-server.com:9000/sso/userinfo

然后在SsoClientController中新增接口

java
// 查询我的账号信息 
@RequestMapping("/sso/myinfo")
public Object myinfo() {
	Object userinfo = CpSsoUtil.getUserinfo(StpUtil.getLoginId());
	System.out.println("--------info:" + userinfo);
	return userinfo;
}

访问测试:http://uaac-sso-client1.com:9001/sso/myinfo

3.3、疑问

群里有小伙伴提问:CpSsoUtil.getUserinfo 提供的参数太少,只有一个 loginId,无法满足业务需求怎么办?

答:CpSsoUtil.getUserinfo只是为了避免你在项目中硬编码认证中心 url 而提供的简易封装,如果这个API无法满足你的业务需求, 你完全可以在 Server 端自定义一些接口然后从 Client 端使用 http 工具调用即可。

4、无刷单点注销

有了单点登录就必然要有单点注销,网上给出的大多数解决方案是将注销请求重定向至SSO-Server中心,逐个通知Client端下线

在某些场景下,页面的跳转可能造成不太好的用户体验,UAAC-SSO 允许你以 REST API 的形式构建接口,做到页面无刷新单点注销。

  1. Client 端在校验 ticket 时,将注销回调地址发送到 Server 端。
  2. Server 端将此 Client 的注销回调地址存储到 Set 集合。
  3. Client 端向 Server 端发送单点注销请求。
  4. Server 端遍历Set集合,逐个通知 Client 端下线。
  5. Server 端注销下线。
  6. 单点注销完成。

这些逻辑 UAAC 内部已经封装完毕,你只需按照文档增加以下配置即可:

4.1、SSO-Client 端新增配置

application.yml 增加配置:API调用秘钥单点注销接口URL

yml
uaac: 
	sso: 
        # 打开单点注销功能 
        is-slo: true
		# 单点注销地址 
		slo-url: http://uaac-sso-server.com:9000/sso/logout
		# 接口调用秘钥 
		secretkey: kQwIOrYvnXmSDkwEiFngrKidMcdrgKor

注意 secretkey 秘钥需要与SSO认证中心的一致

4.2 启动测试

重启项目,访问测试:http://uaac-sso-client1.com:9001/, 我们主要的测试点在于 单点注销,正常登录即可。

点击 [注销] 按钮,即可单点注销成功。

测试完毕!

4、后记

当我们熟读三种模式的单点登录之后,其实不难发现:所谓单点登录,其本质就是多个系统之间的会话共享。

当我们理解这一点之后,三种模式的工作原理也浮出水面:

  • 模式一:采用共享 Cookie 来做到前端 Token 的共享,从而达到后端的 Session 会话共享。
  • 模式二:采用 URL 重定向,以 ticket 码为授权中介,做到多个系统间的会话传播。
  • 模式三:采用 Http 请求主动查询会话,做到 Client 端与 Server 端的会话同步。

SSO整合:配置域名校验


1、Ticket劫持攻击

在前面章节的 SSO-Server 示例中,配置项 uaac.sso.allow-url=* 意为配置所有允许的Client端授权地址,不在此配置项中的URL将无法单点登录成功

为了方便测试,上述代码将其配置为*,但是,**在生产环境中,此配置项绝对不能配置为 *** ,否则会有被 Ticket 劫持的风险

假设攻击者根据模仿我们的授权地址,巧妙的构造一个URL

http://uaac-sso-server.com:9000/sso/auth?redirect=https://www.baidu.com/

当不知情的小红被诱导访问了这个URL时,它将被重定向至百度首页

可以看到,代表着用户身份的 Ticket 码也显现到了URL之中,借此漏洞,攻击者完全可以构建一个URL将小红的 Ticket 码自动提交到攻击者自己的服务器,伪造小红身份登录网站

2、防范方法

造成此漏洞的直接原因就是SSO-Server认证中心没有对 redirect地址 进行任何的限制,防范的方法也很简单,就是对redirect参数进行校验,如果其不在指定的URL列表中时,拒绝下放ticket

我们将其配置为一个具体的URL:allow-url=http://uaac-sso-client1.com:9001/sso/login,再次访问上述连接:

域名没有通过校验,拒绝授权!

3、配置安全性参考表
配置方式举例安全性建议
配置为**生产禁止在生产环境下使用
配置到域名http://uaac-sso-client1.com/*不建议在生产环境下使用
配置到详细地址http://uaac-sso-client1.com:9001/sso/login可以font在生产环境下使用
4、疑问:为什么不直接回传 Token,而是先回传 Ticket,再用 Ticket 去查询对应的账号id?

Token 作为长时间有效的会话凭证,在任何时候都不应该直接暴露在 URL 之中(虽然 Token 直接的暴露本身不会造成安全漏洞,但会为很多漏洞提供可乘之机)

为了不让系统安全处于亚健康状态,UAAC-SSO 选择先回传 Ticket,再由 Ticket 获取账号id,且 Ticket 一次性用完即废,提高安全性。

SSO整合:定制化登录页面


1、何时引导用户去登录?
方案一:前端按钮跳转

前端页面准备一个**[登录]**按钮,当用户点击按钮时,跳转到登录接口

js
<a href="javascript:location.href='/sso/login?back=' + encodeURIComponent(location.href);">登录</a>
方案二:后端拦截重定向

在后端注册全局过滤器(或拦截器、或全局异常处理),拦截需要登录后才能访问的页面资源,将未登录的访问重定向至登录接口

java
/**
 * UAAC 配置类 
 */
@Configuration
public class CpTokenConfigure implements WebMvcConfigurer {
	/** 注册 [UAAC全局过滤器] */
    @Bean
    public CpServletFilter getCpServletFilter() {
        return new CpServletFilter()
        		.addInclude("/**")
        		.addExclude("/sso/*", "/favicon.ico")
        		.setAuth(obj -> {
        			if(StpUtil.isLogin() == false) {
        				String back = CpFoxUtil.joinParam(CpHolder.getRequest().getUrl(), SpringMVCUtil.getRequest().getQueryString());
        				CpHolder.getResponse().redirect("/sso/login?back=" + CpFoxUtil.encodeUrl(back));
        				CpRouter.back();
        			}
        		})
        		;
    }
}
方案三:后端拦截 + 前端跳转

首先,后端仍需要提供拦截,但是不直接引导用户重定向,而是返回未登录的提示信息

java
/**
 * UAAC 配置类 
 */
@Configuration
public class CpTokenConfigure implements WebMvcConfigurer {
	/** 注册 [UAAC全局过滤器] */
    @Bean
    public CpServletFilter getCpServletFilter() {
        return new CpServletFilter()
        		.addInclude("/**")
        		.addExclude("/sso/*", "/favicon.ico")
        		.setAuth(obj -> {
        			if(StpUtil.isLogin() == false) {
        				// 与前端约定好,code=401时代表会话未登录 
        				CpRouter.back(CpResult.ok().setCode(401));
        			}
        		})
        		;
    }
}

前端接受到返回结果 code=401 时,开始跳转至登录接口

js
if(res.code == 401) {
	location.href = '/sso/login?back=' + encodeURIComponent(location.href);
}

这种方案比较适合以 Ajax 访问的 RestAPI 接口重定向

2、如何自定义登录视图?
方式一:在demo示例中直接更改 login.html 页面代码即可
方式二:在配置中配置登录视图地址
java
// 配置:未登录时返回的View 
cfg.sso.setNotLoginView(() -> {
	return new ModelAndView("xxx.html");
})
3、如何自定义登录API的接口地址?

根据需求点选择解决方案:

3.1、如果只是想在 setDoLoginHandle 函数里获取除 name、pwd 以外的参数?
java
// 在任意代码处获取前端提交的参数 
String xxx = CpHolder.getRequest().getParam("xxx");
3.2、想完全自定义一个接口来接受前端登录请求?
java
// 直接定义一个拦截路由为 `/sso/doLogin` 的接口即可 
@RequestMapping("/sso/doLogin")
public CpResult ss(String name, String pwd) {
	System.out.println("------ 请求进入了自定义的API接口 ---------- ");
	if("uaac".equals(name) && "123456".equals(pwd)) {
		StpUtil.login(10001);
		return CpResult.ok("登录成功!");
	}
	return CpResult.error("登录失败!");
}
3.3、不想使用/sso/doLogin这个接口,想自定义一个API地址?

答:直接在前端更改点击按钮时 Ajax 的请求地址即可

SSO整合:自定义 API 路由


方式一:修改全局变量

在之前的章节中,我们演示了如何搭建一个SSO认证中心:

java
/**
 * UAAC-SSO Server端 Controller 
 */
@RestController
public class SsoServerController {

	// SSO-Server端:处理所有SSO相关请求 
	@RequestMapping("/sso/*")
	public Object ssoRequest() {
		return CpSsoHandle.serverRequest();
	}
	
	// ... 其它代码
	
}

这种写法集成简单但却不够灵活。例如认证中心地址只能是:http://{host}:{port}/sso/auth,如果我们想要自定义其API地址,应该怎么做呢?

我们可以打开SSO模块相关源码,有关 API 的设计都定义在:CpSsoConsts.java 中,这些值从架构设计上来讲属于常量却并未使用 final 修饰,目的就是为了方便我们对其二次修改。

例如,我们可以在 Main 方法启动类或者 SSO 配置方法中修改变量值:

java
// 配置SSO相关参数 
@Autowired
private void configSso(CpConfig cfg) {
	// 自定义API地址
	CpSsoConsts.Api.ssoAuth = "/sso/auth2";
	// ... 
	
	// SSO 相关配置
	cfg.sso.setXxx ... ;
}

启动项目,统一认证地址就被我们修改成了:http://{host}:{port}/sso/auth2

方式二:拆分路由入口

根据上述路由入口:@RequestMapping("/sso/*"),我们给它起一个合适的名字 —— 聚合式路由。

与之对应的,我们可以将其修改为拆分式路由:

java
/**
 * UAAC-SSO Server端 Controller 
 */
@RestController
public class SsoServerController {

	// SSO-Server:统一认证地址 
	@RequestMapping("/sso/auth")
	public Object ssoAuth() {
		return CpSsoHandle.ssoAuth();
	}

	// SSO-Server:RestAPI 登录接口 
	@RequestMapping("/sso/doLogin")
	public Object ssoDoLogin() {
		return CpSsoHandle.ssoDoLogin();
	}

	// SSO-Server:校验ticket 获取账号id 
	@RequestMapping("/sso/checkTicket")
	public Object ssoCheckTicket() {
		return CpSsoHandle.ssoCheckTicket();
	}

	// SSO-Server:单点注销 
	@RequestMapping("/sso/logout")
	public Object ssoLogout() {
		return CpSsoHandle.ssoServerLogout();
	}
	
	// ... 其它方法 
	
}

拆分式路由 与 聚合式路由 在功能上完全等价,且提供了更为细致的路由管控。

SSO整合:前后端分离架构下的整合方案


如果我们已有的系统是前后端分离模式,我们显然不能为了接入SSO而改造系统的基础架构,官方仓库的示例采用的是前后端一体方案,要将其改造为前后台分离架构模式非常简单

uaac-demo-sso2-client为例:

1、新建H5Controller开放接口
java
/**
 * 前后台分离架构下集成SSO所需的代码 
 */
@RestController
public class H5Controller {

	// 当前是否登录 
	@RequestMapping("/isLogin")
	public Object isLogin() {
		return CpResult.data(StpUtil.isLogin());
	}
	
	// 返回SSO认证中心登录地址 
	@RequestMapping("/getSsoAuthUrl")
	public CpResult getSsoAuthUrl(String clientLoginUrl) {
		String serverAuthUrl = CpSsoUtil.buildServerAuthUrl(clientLoginUrl, "");
		return CpResult.data(serverAuthUrl);
	}
	
	// 根据ticket进行登录 
	@RequestMapping("/doLoginByTicket")
	public CpResult doLoginByTicket(String ticket) {
		Object loginId = CpSsoHandle.checkTicket(ticket, "/doLoginByTicket");
		if(loginId != null) {
			StpUtil.login(loginId);
			return CpResult.data(StpUtil.getTokenValue());
		}
		return CpResult.error("无效ticket:" + ticket); 
	}

	// 全局异常拦截 
	@ExceptionHandler
	public CpResult handlerException(Exception e) {
		e.printStackTrace(); 
		return CpResult.error(e.getMessage());
	}
	
}
2、增加跨域过滤器CorsFilter.java

源码详见:CorsFilter.java, 将其复制到项目中即可

3、新建前端项目

任意文件夹新建前端项目:uaac-demo-sso-client-h5,在根目录添加测试文件:index.html

js
<!DOCTYPE html>
<html>
	<head>
		<meta charset="utf-8">
		<title>UAAC-SSO-Client端-测试页(前后端分离版)</title>
	</head>
	<body>
		<h2>UAAC SSO-Client 应用端(前后端分离版)</h2>
		<p>当前是否登录:<b class="is-login"></b></p>
		<p>
			<a href="javascript:location.href='sso-login.html?back=' + encodeURIComponent(location.href);">登录</a>
			<a href="javascript:location.href=baseUrl + '/sso/logout?uaac-token=' + localStorage.uaac-token + '&back=' + encodeURIComponent(location.href);">注销</a>
		</p>
		<script src="https://unpkg.zhimg.com/jquery@3.4.1/dist/jquery.min.js"></script>
		<script type="text/javascript">
		
			// 后端接口地址 
			var baseUrl = "http://uaac-sso-client1.com:9001";
				
			// 查询当前会话是否登录 
			$.ajax({
				url: baseUrl + '/isLogin',
				type: "post", 
				dataType: 'json',
				headers: {
					"X-Requested-With": "XMLHttpRequest",
					"uaac-token": localStorage.getItem("uaac-token")
				},
				success: function(res){
					$('.is-login').html(res.data + '');
				},
				error: function(xhr, type, errorThrown){
					return alert("异常:" + JSON.stringify(xhr));
				}
			});
			
		</script>
	</body>
</html>
4、添加登录处理文件sso-login.html

源码详见:sso-login.html, 将其复制到项目中即可,与index.html一样放在根目录下

5、测试运行

先启动Server服务端与Client服务端,再随便找个能预览html的工具打开前端项目(比如HBuilderX),测试流程与一体版一致

6、SSO-Server 端的前后台分离

疑问:上述代码都是针对 Client 端进行拆分,如果我想在 SSO-Server 端也进行前后台分离改造,应该怎么做?

答:解决思路都是大同小异的,与Client一样,我们需要把原本在 “后端处理的授权重定向逻辑” 拿到前端来实现。

由于集成代码与 Client 端类似,这里暂不贴详细代码,我们可以下载官方仓库,里面有搭建好的demo

使用前端ide导入项目 /uaac-demo/uaac-demo-sso-server-h5,浏览器访问 sso-auth.html 页面:

复制上述地址,将其配置到 Client 端的 yml 配置文件中,例如:

yml
uaac:
    sso: 
		# SSO-Server端 统一认证地址 
	    auth-url: http://127.0.0.1:8848/uaac-demo-sso-server-h5/sso-auth.html

然后我们启动项目 uaac-demo-sso2-serveruaac-demo-sso2-client,按照之前的测试步骤访问: http://uaac-sso-client1.com:9001/,即可以前后端分离模式完成 SSO-Server 端的授权登录。

SSO整合:常见问题总结


问:在模式一与模式二中,Client端 必须通过 Alone-Redis 插件来访问Redis吗?

答:不必须,只是推荐,权限缓存与业务缓存分离后会减少 SSO-Redis 的访问压力,且可以避免多个 Client端 的缓存读写冲突

问:将旧有系统改造为单点登录时,应该注意哪些?

答:建议不要把其中一个系统改造为SSO服务端,而是新起一个项目作为Server端,所有旧有项目全部作为Client端与此对接

问:SSO模式二,第一个域名登录成功之后其他两个不会自动登录?

答:系统1登录成功之后,系统二与系统三需要点击登录按钮,才会登录成功

第一个系统,需要:点击 [登录] 按钮 -> 跳转到登录页 -> 输账号密码 -> 登录成功 第二个系统,需要:点击 [登录] 按钮 -> 登录成功 第三个系统,需要:点击 [登录] 按钮 -> 登录成功 (免去重复跳转登录页输入账号密码的步骤)

追问:那我是否可以设计成不需要点登录按钮的,只要访问页面,它就能登录成功

可以。加个过滤器检测到未登录自动跳转就行了,详细可以参照章节<何时引导用户去登录>给出的建议进行设计。

问:我参照文档的SSO模式二搭建,一直提示:Ticket无效,请问怎么回事?

根据群友的反馈,出现此异常概率最大的原因是因为 ClientServer 没有连接同一个Redis,SSO模式二中两者必须连接同一个 Redis 才可以登录成功, 如果您排查之后不是此原因,可以加入QQ群或者在issues反馈一下

还有其它问题?

可以加群反馈一下,比较典型的问题我们解决之后都会提交到此页面方便大家快速排查

OAuth2.0

OAuth2.0简述


什么是OAuth2.0?解决什么问题?

简单来讲,OAuth2.0的应用场景可以理解为单点登录的升级版,单点登录解决了多个系统间会话的共享,OAuth2.0在此基础上增加了应用之间的权限控制 (SO:有些系统采用OAuth2.0模式实现了单点登录,但这总给人一种“杀鸡焉用宰牛刀”的感觉)

有关OAuth2.0的设计思想网上教程较多,此处不再重复赘述,详细可参考博客: OAuth2.0 简单解释

如果你还不知道你的项目应该选择 SSO 还是 OAuth2.0,可以参考:技术选型:[ 单点登录 ] VS [ OAuth2.0 ]

OAuth2.0 四种模式

UAAC-OAuth2 模块基于 RFC-6749 标准 编写,基于不同的使用场景,OAuth2.0设计了四种模式:

  1. 授权码(Authorization Code):OAuth2.0标准授权步骤,Server端向Client端下放Code码,Client端再用Code码换取授权Token
  2. 隐藏式(Implicit):无法使用授权码模式时的备用选择,Server端使用URL重定向方式直接将Token下放到Client端页面
  3. 密码式(Password):Client直接拿着用户的账号密码换取授权Token
  4. 客户端凭证(Client Credentials):Server端针对Client级别的Token,代表应用自身的资源授权

接下来我们将通过简单示例演示如何在UAAC-OAuth2中完成这四种模式的对接: 搭建OAuth2-Server

OAuth2 Server搭建


1、准备工作

首先修改hosts文件(C:\windows\system32\drivers\etc\hosts),添加以下IP映射,方便我们进行测试:

url
127.0.0.1 uaac-oauth-server.com
127.0.0.1 uaac-oauth-client.com
2、引入依赖

创建SpringBoot项目 uaac-demo-oauth2-server(不会的同学自行百度或参考仓库示例),添加pom依赖:

xml
<!-- UAAC 权限认证 -->
<dependency>
	<groupId>com.cpit.csc</groupId>
	<artifactId>uaac-spring-boot-starter</artifactId>
	<version>${uaac.top.version}</version>
</dependency>

<!-- UAAC-OAuth2.0 模块 -->
<dependency>
	<groupId>com.cpit.csc</groupId>
	<artifactId>uaac-oauth2</artifactId>
	<version>${uaac.top.version}</version>
</dependency>
3、开放服务

1、新建 CpOAuth2TemplateImpl

java
/**
 * UAAC OAuth2.0 整合实现 
 */
@Component
public class CpOAuth2TemplateImpl extends CpOAuth2Template {
	
	// 根据 id 获取 Client 信息 
	@Override
	public CpClientModel getClientModel(String clientId) {
		// 此为模拟数据,真实环境需要从数据库查询 
		if("1001".equals(clientId)) {
			return new CpClientModel()
					.setClientId("10001")
					.setClientSecret("aaaa-bbbb-cccc-dddd-eeee")
					.setAllowUrl("*")
					.setContractScope("userinfo");
		}
		return null;
	}
	
	// 根据ClientId 和 LoginId 获取openid 
	@Override
	public String getOpenid(String clientId, Object loginId) {
		// 此为模拟数据,真实环境需要从数据库查询 
		return "gr_SwoIN0MC1ewxHX_vfCW3BothWDZMMtx__";
	}
	
}

2、新建CpOAuth2ServerController

java
/**
 * UAAC-OAuth2 Server端 控制器 
 */
@RestController
public class CpOAuth2ServerController {

	// 处理所有OAuth相关请求 
	@RequestMapping("/oauth2/*")
	public Object request() {
		System.out.println("------- 进入请求: " + CpHolder.getRequest().getUrl());
		return CpOAuth2Handle.serverRequest();
	}
	
	// UAAC-OAuth2 定制化配置 
	@Autowired
	public void setCpOAuth2Config(CpOAuth2Config cfg) {
		cfg.
			// 配置:未登录时返回的View 
			setNotLoginView(() -> {
				String msg = "当前会话在SSO-Server端尚未登录,请先访问"
	                        + "<a href='/oauth2/doLogin?name=uaac&pwd=123456' target='_blank'> doLogin登录 </a>"
	                        + "进行登录之后,刷新页面开始授权";
	            return msg;
			}).
			// 配置:登录处理函数 
			setDoLoginHandle((name, pwd) -> {
				if("uaac".equals(name) && "123456".equals(pwd)) {
					StpUtil.login(10001);
					return CpResult.ok();
				}
				return CpResult.error("账号名或密码错误");
			}).
			// 配置:确认授权时返回的View 
			setConfirmView((clientId, scope) -> {
				String msg = "<p>应用 " + clientId + " 请求授权:" + scope + "</p>"
                        + "<p>请确认:<a href='/oauth2/doConfirm?client_id=" + clientId + "&scope=" + scope + "' target='_blank'> 确认授权 </a></p>"
                        + "<p>确认之后刷新页面</p>";
				return msg;
			})
			;
	}

	// 全局异常拦截  
	@ExceptionHandler
	public CpResult handlerException(Exception e) {
		e.printStackTrace(); 
		return CpResult.error(e.getMessage());
	}
	
}

注意:在setDoLoginHandle函数里如果要获取name, pwd以外的参数,可通过CpHolder.getRequest().getParam("xxx")来获取

3、创建启动类:

java
/**
 * 启动:UAAC-OAuth2 Server端 
 */
@SpringBootApplication 
public class UaaCOAuth2ServerApplication {
	public static void main(String[] args) {
		SpringApplication.run(UaaCOAuth2ServerApplication.class, args);
		System.out.println("\nUAAC-OAuth Server端启动成功");
	}
}

启动项目

4、访问测试

1、以example.com为重定向URL为例:

url
http://uaac-oauth-server.com:8001/oauth2/authorize?response_type=code&client_id=1001&redirect_uri=http://example.com/&scope=userinfo

2、由于首次访问,我们在OAuth-Server端暂未登录,会被转发到登录视图

3、点击doLogin进行登录之后刷新页面,会提示我们确认授权

4、点击确认授权之后刷新页面,我们会被重定向至 redirect_uri 页面,并携带了code参数

5、我们拿着code参数,访问以下地址:

url
http://uaac-oauth-server.com:8001/oauth2/token?grant_type=authorization_code&client_id=1001&client_secret=aaaa-bbbb-cccc-dddd-eeee&code={code}

将得到 Access-TokenRefresh-Tokenopenid等授权信息

测试完毕

5、运行官方示例

以上代码只是简单模拟了一下OAuth2.0的授权流程,现在,我们运行一下官方示例,里面有制作好的UI界面

  • OAuth2-Server端: /uaac-demo/uaac-demo-oauth2-server/
  • OAuth2-Client端: /uaac-demo/uaac-demo-oauth2-client/

依次启动OAuth2-ServerOAuth2-Client,然后从浏览器访问:http://uaac-oauth-client.com:8002

如图,可以针对OAuth2.0四种模式进行详细测试

OAuth2 Server端API列表

基于官方仓库的搭建示例,OAuth2-Server端会暴露出以下API,OAuth2-Client端可据此文档进行对接


1、模式一:授权码(Authorization Code)

1.1、获取授权码

根据以下格式构建URL,引导用户访问 (复制时请注意删减掉相应空格和换行符)

url
http://uaac-oauth-server.com:8001/oauth2/authorize
	?response_type=code
	&client_id={value}
	&redirect_uri={value}
	&scope={value}
	$state={value}

参数详解:

参数是否必填说明
response_type返回类型,这里请填写:code
client_id应用id
redirect_uri用户确认授权后,重定向的url地址
scope具体请求的权限,多个用逗号隔开
state随机值,此参数会在重定向时追加到url末尾,不填不追加

注意点:

  1. 如果用户在Server端尚未登录:会被转发到登录视图,你可以参照文档或官方示例自定义登录页面
  2. 如果scope参数为空,或者请求的权限用户近期已确认过,则无需用户再次确认,达到静默授权的效果,否则需要用户手动确认,服务器才可以下放code授权码

用户确认授权之后,会被重定向至redirect_uri,并追加code参数与state参数,形如:

url
redirect_uri?code={code}&state={state}

Code授权码具有以下特点:

  1. 每次授权产生的Code码都不一样
  2. Code码用完即废,不能二次使用
  3. 一个Code的有效期默认为五分钟,超时自动作废
  4. 每次授权产生新Code码,会导致旧Code码立即作废,即使旧Code码尚未使用
1.2、根据授权码获取Access-Token

获得Code码后,我们可以通过以下接口,获取到用户的Access-TokenRefresh-Tokenopenid等关键信息

url
http://uaac-oauth-server.com:8001/oauth2/token
	?grant_type=authorization_code
	&client_id={value}
	&client_secret={value}
	&code={value}

参数详解:

参数是否必填说明
grant_type授权类型,这里请填写:authorization_code
client_id应用id
client_secret应用秘钥
code步骤1.1中获取到的授权码

接口返回示例:

js
{
    "code": 200,	// 200表示请求成功,非200标识请求失败, 以下不再赘述 
    "msg": "ok",
    "data": {
        "access_token": "7Ngo1Igg6rieWwAmWMe4cxT7j8o46mjyuabuwLETuAoN6JpPzPO2i3PVpEVJ",     // Access-Token值
        "refresh_token": "ZMG7QbuCVtCIn1FAJuDbgEjsoXt5Kqzii9zsPeyahAmoir893ARA4rbmeR66",    // Refresh-Token值
        "expires_in": 7199,                 // Access-Token剩余有效期,单位秒  
        "refresh_expires_in": 2591999,      // Refresh-Token剩余有效期,单位秒  
        "client_id": "1001",                // 应用id
        "scope": "userinfo",                // 此令牌包含的权限
        "openid": "gr_SwoIN0MC1ewxHX_vfCW3BothWDZMMtx__"     // openid 
    }
}
1.3、根据 Refresh-Token 刷新 Access-Token (如果需要的话)

Access-Token的有效期较短,如果每次过期都需要重新授权的话,会比较影响用户体验,因此我们可以在后台通过Refresh-Token 刷新 Access-Token

url
http://uaac-oauth-server.com:8001/oauth2/refresh
	?grant_type=refresh_token
	&client_id={value}
	&client_secret={value}
	&refresh_token={value}

参数详解:

参数是否必填说明
grant_type授权类型,这里请填写:refresh_token
client_id应用id
client_secret应用秘钥
refresh_token步骤1.2中获取到的Refresh-Token

接口返回值同章节1.2,此处不再赘述

1.4、回收 Access-Token (如果需要的话)

在Access-Token过期前主动将其回收

url
http://uaac-oauth-server.com:8001/oauth2/revoke
	?client_id={value}
	&client_secret={value}
	&access_token={value}

参数详解:

参数是否必填说明
client_id应用id
client_secret应用秘钥
access_token步骤1.2中获取到的Access-Token

返回值样例:

js
{
    "code": 200,
    "msg": "ok",
    "data": null
}
1.5、根据 Access-Token 获取相应用户的账号信息

注:此接口为官方仓库模拟接口,正式项目中大家可以根据此样例,自定义需要的接口及参数

url
http://uaac-oauth-server.com:8001/oauth2/userinfo?access_token={value}

返回值样例:

js
{
    "code": 200,
    "msg": "ok",
    "data": {
        "nickname": "shengzhang_",         // 账号昵称
        "avatar": "http://xxx.com/1.jpg",  // 头像地址
        "age": "18",                       // 年龄
        "sex": "",                       // 性别
        "address": "山东省 青岛市 城阳区"   // 所在城市 
    }
}

2、模式二:隐藏式(Implicit)

根据以下格式构建URL,引导用户访问:

url
http://uaac-oauth-server.com:8001/oauth2/authorize
	?response_type=token
	&client_id={value}
	&redirect_uri={value}
	&scope={value}
	$state={value}

参数详解:

参数是否必填说明
response_type返回类型,这里请填写:token
client_id应用id
redirect_uri用户确认授权后,重定向的url地址
scope具体请求的权限,多个用逗号隔开
state随机值,此参数会在重定向时追加到url末尾,不填不追加

此模式会越过授权码的步骤,直接返回Access-Token到前端页面,形如:

url
redirect_uri#token=xxxx-xxxx-xxxx-xxxx

3、模式三:密码式(Password)

首先在Client端构建表单,让用户输入Server端的账号和密码,然后在Client端访问接口

url
http://uaac-oauth-server.com:8001/oauth2/token
	?grant_type=password
	&client_id={value}
	&username={value}
	&password={value}

参数详解:

参数是否必填说明
grant_type返回类型,这里请填写:password
client_id应用id
username用户的Server端账号
password用户的Server端密码
scope具体请求的权限,多个用逗号隔开

接口返回示例:

js
{
    "code": 200,	// 200表示请求成功,非200标识请求失败, 以下不再赘述 
    "msg": "ok",
    "data": {
        "access_token": "7Ngo1Igg6rieWwAmWMe4cxT7j8o46mjyuabuwLETuAoN6JpPzPO2i3PVpEVJ",     // Access-Token值
        "refresh_token": "ZMG7QbuCVtCIn1FAJuDbgEjsoXt5Kqzii9zsPeyahAmoir893ARA4rbmeR66",    // Refresh-Token值
        "expires_in": 7199,                 // Access-Token剩余有效期,单位秒  
        "refresh_expires_in": 2591999,      // Refresh-Token剩余有效期,单位秒  
        "client_id": "1001",                // 应用id
        "scope": "",                        // 此令牌包含的权限
        "openid": "gr_SwoIN0MC1ewxHX_vfCW3BothWDZMMtx__"     // openid 
    }
}

4、模式四:凭证式(Client Credentials)

以上三种模式获取的都是用户的 Access-Token,代表用户对第三方应用的授权, 在OAuth2.0中还有一种针对 Client级别的授权, 即:Client-Token,代表应用自身的资源授权

在Client端的后台访问以下接口:

url
http://uaac-oauth-server.com:8001/oauth2/client_token
	?grant_type=client_credentials
	&client_id={value}
	&client_secret={value}

参数详解:

参数是否必填说明
grant_type返回类型,这里请填写:client_credentials
client_id应用id
client_secret应用秘钥
scope申请权限

接口返回值样例:

js
{
    "code": 200,
    "msg": "ok",
    "data": {
        "client_token": "HmzPtaNuIqGrOdudWLzKJRSfPadN497qEJtanYwE7ZvHQWDy0jeoZJuDIiqO",	// Client-Token 值
        "expires_in": 7199,     // Token剩余有效时间,单位秒 
        "client_id": "1001",    // 应用id
        "scope": null           // 包含权限 
    }
}

注:Client-Token具有延迟作废特性,即:在每次获取最新Client-Token的时候,旧Client-Token不会立即过期,而是作为Past-Token再次储存起来, 资源请求方只要携带其中之一便可通过Token校验,这种特性保证了在大量并发请求时不会出现“新旧Token交替造成的授权失效”, 保证了服务的高可用

OAuth2二次开发说明

官方示例只提供了基本的授权流程,以及userinfo资源的开放,如果您需要开放更多的接口,则二次开发时用到以下相关API方法


UAAC-OAuth2 模块常用方法

java
// 根据 id 获取 Client 信息, 如果 Client 为空,则抛出异常 
CpOAuth2Util.checkClientModel(clientId);

// 获取 Access-Token,如果Access-Token为空则抛出异常 
CpOAuth2Util.checkAccessToken(accessToken);

// 获取 Client-Token,如果Client-Token为空则抛出异常
CpOAuth2Util.checkClientToken(clientToken);

// 获取 Access-Token 所代表的LoginId
CpOAuth2Util.getLoginIdByAccessToken(accessToken);

// 校验:指定 Access-Token 是否具有指定 Scope
CpOAuth2Util.checkScope(accessToken, scopes);

// 根据 code码 生成 Access-Token 
CpOAuth2Util.generateAccessToken(code);

// 根据 Refresh-Token 生成一个新的 Access-Token
CpOAuth2Util.refreshAccessToken(refreshToken);

// 构建 Client-Token 
CpOAuth2Util.generateClientToken(clientId, scope);

// 回收 Access-Token 
CpOAuth2Util.revokeAccessToken(accessToken);

// 持久化:用户授权记录 
CpOAuth2Util.saveGrantScope(clientId, loginId, scope);

// 获取:Refresh-Token Model
CpOAuth2Util.getRefreshToken(refreshToken);

详情请参考源码CpOAuth2Util.java

微服务

分布式Session会话


需求场景

微服务架构下的第一个难题便是数据同步,单机版的Session在分布式环境下一般不能正常工作,为此我们需要对框架做一些特定的处理。

首先我们要明白,分布式环境下为什么Session会失效?因为用户在一个节点对会话做出的更改无法实时同步到其它的节点, 这就导致一个很严重的问题:如果用户在节点一上已经登录成功,那么当下一次的请求落在节点二上时,对节点二来讲,此用户仍然是未登录状态。

解决方案

要怎么解决这个问题呢?目前的主流方案有四种:

  1. Session同步:只要一个节点的数据发生了改变,就强制同步到其它所有节点
  2. Session粘滞:通过一定的算法,保证一个用户的所有请求都稳定的落在一个节点之上,对这个用户来讲,就好像还是在访问一个单机版的服务
  3. 建立会话中心:将Session存储在专业的缓存中间件上,使每个节点都变成了无状态服务,例如:Redis
  4. 颁发无状态token:放弃Session机制,将用户数据直接写入到令牌本身上,使会话数据做到令牌自解释,例如:jwt
方案选择

该如何选择一个合适的方案?

  • 方案一:性能消耗太大,不太考虑
  • 方案二:需要从网关处动手,与框架无关
  • 方案三:UAAC 整合Redis非常简单,详见章节:集成 Redis
  • 方案四:详见官方仓库中 UAAC 整合jwt的示例

由于jwt模式不在服务端存储数据,对于比较复杂的业务可能会功能受限,因此更加推荐使用方案三

xml
<!-- UAAC 整合 Redis (使用jackson序列化方式) -->
<dependency>
    <groupId>com.cpit.csc</groupId>
    <artifactId>uaac-dao-redis-jackson</artifactId>
    <version>${uaac.top.version}</version>
</dependency>
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>

详细参考:集成 Redis

网关统一鉴权

微服务架构下的鉴权一般分为两种:

  1. 每个服务各自鉴权
  2. 网关统一鉴权

方案一和传统单体鉴权差别不大,不再过多赘述,本篇介绍方案二的整合步骤:


1、引入依赖

首先,根据章节“依赖引入说明” 引入正确的依赖,以[SpringCloud Gateway]为例:

xml
<!-- UAAC 权限认证(Reactor响应式集成) -->
<dependency>
    <groupId>com.cpit.csc</groupId>
    <artifactId>uaac-reactor-spring-boot-starter</artifactId>
    <version>${uaac.top.version}</version>
</dependency>
<!-- UAAC 整合 Redis (使用jackson序列化方式) -->
<dependency>
    <groupId>com.cpit.csc</groupId>
    <artifactId>uaac-dao-redis-jackson</artifactId>
    <version>${uaac.top.version}</version>
</dependency>
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>

注:Redis包是必须的,因为我们需要和各个服务通过Redis来同步数据

2、实现鉴权接口
java
/**
 * 自定义权限验证接口扩展 
 */
@Component   
public class StpInterfaceImpl implements StpInterface {

    @Override
    public List<String> getPermissionList(Object loginId, String loginType) {
        // 返回此 loginId 拥有的权限列表 
        return ...;
    }

    @Override
    public List<String> getRoleList(Object loginId, String loginType) {
        // 返回此 loginId 拥有的角色列表
        return ...;
    }

}

关于数据的获取,建议以下方案三选一:

  1. 在网关处集成ORM框架,直接从数据库查询数据
  2. 先从Redis中获取数据,获取不到时走ORM框架查询数据库
  3. 先从Redis中获取缓存数据,获取不到时走RPC调用子服务 (专门的权限数据提供服务) 获取
3、注册全局过滤器

然后在网关处注册全局过滤器进行鉴权操作

java
/**
 * [UAAC 权限认证] 配置类
 */
@Configuration
public class CpTokenConfigure {
	// 注册 UAAC全局过滤器 
    @Bean
    public CpReactorFilter getCpReactorFilter() {
        return new CpReactorFilter()
			// 拦截地址 
			.addInclude("/**")
			// 开放地址 
			.addExclude("/favicon.ico")
			// 鉴权方法:每次访问进入 
			.setAuth(obj -> {
				// 登录校验 -- 拦截所有路由,并排除/user/doLogin 用于开放登录 
				CpRouter.match("/**", "/user/doLogin", r -> StpUtil.checkLogin());
				
				// 权限认证 -- 不同模块, 校验不同权限 
				CpRouter.match("/user/**", r -> StpUtil.checkPermission("user"));
				CpRouter.match("/admin/**", r -> StpUtil.checkPermission("admin"));
				CpRouter.match("/goods/**", r -> StpUtil.checkPermission("goods"));
				CpRouter.match("/orders/**", r -> StpUtil.checkPermission("orders"));
				
				// ... 
			})
			// 异常处理方法:每次setAuth函数出现异常时进入 
			.setError(e -> {
				return CpResult.error(e.getMessage());
			})
			;
    }
}

详细操作参考:路由拦截鉴权

内部服务外网隔离


一、需求场景

我们的子服务一般不能通过外网直接访问,必须通过网关转发才是一个合法的请求,这种子服务与外网的隔离一般分为两种:

  1. 物理隔离:子服务部署在指定的内网环境中,只有网关对外网开放
  2. 逻辑隔离:子服务与网关同时暴露在外网,但是子服务会有一个权限拦截层保证只接受网关发送来的请求,绕过网关直接访问子服务会被提示:无效请求

这种鉴权需求牵扯到两个环节:网关转发鉴权服务内部调用鉴权

UAAC提供两种解决方案:

  1. 使用 OAuth2.0 模式的凭证式,将 Client-Token 用作各个服务的身份凭证进行权限校验
  2. 使用 Id-Token 模块提供的身份校验能力,完成服务间的权限认证

本篇主要讲解方案二 Id-Token 模块的整合步骤,其鉴权流程与 OAuth2.0 类似,不过使用方式上更加简洁(希望使用方案一的同学可参考UAAC-OAuth2模块,此处不再赘述)

二、网关转发鉴权
1、引入依赖

在网关处引入的依赖为(此处以 SpringCloud Gateway 为例):

xml
<!-- UAAC 权限认证(Reactor响应式集成) -->
<dependency>
    <groupId>com.cpit.csc</groupId>
    <artifactId>uaac-reactor-spring-boot-starter</artifactId>
    <version>${uaac.top.version}</version>
</dependency>
<!-- UAAC 整合 Redis (使用jackson序列化方式) -->
<dependency>
    <groupId>com.cpit.csc</groupId>
    <artifactId>uaac-dao-redis-jackson</artifactId>
    <version>${uaac.top.version}</version>
</dependency>
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>

在子服务引入的依赖为:

xml
<!-- UAAC 权限认证 -->
<dependency>
    <groupId>com.cpit.csc</groupId>
    <artifactId>uaac-spring-boot-starter</artifactId>
    <version>${uaac.top.version}</version>
</dependency>
<!-- UAAC 整合 Redis (使用jackson序列化方式) -->
<dependency>
    <groupId>com.cpit.csc</groupId>
    <artifactId>uaac-dao-redis-jackson</artifactId>
    <version>${uaac.top.version}</version>
</dependency>
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>
2、网关处添加Id-Token

为网关添加全局过滤器:

java
/**
 * 全局过滤器,为请求添加 Id-Token 
 */
@Component
public class ForwardAuthFilter implements GlobalFilter {
	@Override
	public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
		ServerHttpRequest newRequest = exchange
				.getRequest()
				.mutate()
				// 为请求追加 Id-Token 参数 
				.header(CpIdUtil.ID_TOKEN, CpIdUtil.getToken())
				.build();
        ServerWebExchange newExchange = exchange.mutate().request(newRequest).build();
        return chain.filter(newExchange);
	}
}

此过滤器会为 Request 请求头追加 Id-Token 参数,这个参数会被转发到子服务

3、在子服务里校验参数

在子服务添加过滤器校验参数

java
/**
 * UAAC 权限认证 配置类 
 */
@Configuration
public class CpTokenConfigure implements WebMvcConfigurer {
	// 注册 UAAC 全局过滤器 
    @Bean
    public CpServletFilter getCpServletFilter() {
        return new CpServletFilter()
        		.addInclude("/**")
        		.addExclude("/favicon.ico")
        		.setAuth(obj -> {
        			// 校验 Id-Token 身份凭证 	—— 以下两句代码可简化为:CpIdUtil.checkCurrentRequestToken(); 
        			String token = CpHolder.getRequest().getHeader(CpIdUtil.ID_TOKEN);
        			CpIdUtil.checkToken(token);
        		})
        		.setError(e -> {
        			return CpResult.error(e.getMessage());
        		})
        		;
    }
}

启动网关与子服务,访问测试:

如果通过网关转发,可以正常访问,直接访问子服务会提示:无效Id-Token:xxx

三、服务内部调用鉴权

有时候我们需要在一个服务调用另一个服务的接口,这也是需要添加Id-Token作为身份凭证的

在服务里添加 Id-Token 流程与网关类似,我们以RPC框架 Feign 为例:

1、首先在调用方添加 FeignInterceptor
java
/**
 * feign拦截器, 在feign请求发出之前,加入一些操作 
 */
@Component
public class FeignInterceptor implements RequestInterceptor {
	// 为 Feign 的 RCP调用 添加请求头Id-Token 
	@Override
	public void apply(RequestTemplate requestTemplate) {
		requestTemplate.header(CpIdUtil.ID_TOKEN, CpIdUtil.getToken());
	}
}
2、在调用接口里使用此 Interceptor
java
/**
 * 服务调用 
 */
@FeignClient(
		name = "sp-home", 				// 服务名称 
		configuration = FeignInterceptor.class,		// 请求拦截器 (关键代码)
		fallbackFactory = SpCfgInterfaceFallback.class	// 服务降级处理 
		)	
public interface SpCfgInterface {

	// 获取server端指定配置信息 
	@RequestMapping("/SpConfig/getConfig")
	public String getConfig(@RequestParam("key")String key);
	
}

被调用方的代码无需更改(按照网关转发鉴权处的代码注册全局过滤器),保持启动测试即可

四、Id-Token 模块详解

Id-Token —— 专门解决身份凭证问题的一个模块,它的作用不仅局限于微服务调用场景

基本使用流程为:服务调用方获取Token,提交到请求中,被调用方取出Token进行校验:Token一致则校验通过,否则拒绝服务

首先我们预览一下此模块的相关API:

java
// 获取当前Id-Token
CpIdUtil.getToken();

// 判断一个Id-Token是否有效
CpIdUtil.isValid(token);

// 校验一个Id-Token是否有效 (如果无效则抛出异常)
CpIdUtil.checkToken(token);

// 校验当前Request提供的Id-Token是否有效 (如果无效则抛出异常)
CpIdUtil.checkCurrentRequestToken();

// 刷新一次Id-Token (注意集群环境中不要多个服务重复调用) 
CpIdUtil.refreshToken();

// 在 Request 上储存 Id-Token 时建议使用的key
CpIdUtil.ID_TOKEN;
1、疑问:这个Token保存在什么地方?有没有泄露的风险?Token为永久有效还是临时有效?

Id-Token 默认随 UAAC 数据一起保存在Redis中,理论上不会存在泄露的风险,每个Token默认有效期只有一天

2、如何主动刷新Id-Token,例如:五分钟、两小时刷新一次?

Id-Token 刷新间隔越短,其安全性越高,每个Token的默认有效期为一天,在一天后再次获取会自动产生一个新的Token

需要注意的一点是:Id-Token默认的自刷新机制,并不能做到高并发可用,多个服务一起触发Token刷新可能会造成毫秒级的短暂服务失效,其只能适用于 项目开发阶段 或 低并发业务场景

因此在微服务架构下,我们需要有专门的机制主动刷新Id-Token,保证其高可用

例如,我们可以专门起一个服务,使用定时任务来刷新Id-Token

java
/**
 * Id-Token,定时刷新
 */
@Configuration
public class CpIdTokenRefreshTask {
	// 从 0 分钟开始 每隔 5 分钟执行一次 Id-Token  
	@Scheduled(cron = "0 0/5 * * * ? ")
	public void refreshToken(){
		CpIdUtil.refreshToken();
	}
}

以上的cron表达式刷新间隔可以配置为五分钟十分钟两小时,只要低于Id-Token的有效期(默认为一天)即可。

3、如果网关携带token转发的请求在落到子服务的节点上时,恰好刷新了token,导致鉴权未通过怎么办?

Id-Token 模块在设计时,充分考虑到了这一点,在每次刷新 Token 时,旧 Token 会被作为次级 Token 存储起来, 只要网关携带的 Token 符合新旧 Token 其一即可通过认证,直至下一次刷新,新 Token 再次作为次级 Token 将此替换掉

依赖引入说明


虽然在 [开始] 章节已经说明了依赖引入规则,但是交流群里不少小伙伴提出bug解决到最后发现都是因为依赖引入错误导致的,此处再次重点强调一下:

在微服务架构中使用UAAC时,网关和内部服务要分开引入UAAC依赖(不要直接在顶级父pom中引入UAAC)

总体来讲,我们需要关注的依赖就是两个:uaac-spring-boot-starteruaac-reactor-spring-boot-starter

xml
<!-- UAAC 权限认证 -->
<dependency>
    <groupId>com.cpit.csc</groupId>
    <artifactId>uaac-spring-boot-starter</artifactId>
    <version>${uaac.top.version}</version>
</dependency>
xml
<!-- UAAC 权限认证(Reactor响应式集成) -->
<dependency>
    <groupId>com.cpit.csc</groupId>
    <artifactId>uaac-reactor-spring-boot-starter</artifactId>
    <version>${uaac.top.version}</version>
</dependency>

至于怎么分辨我们需要引入哪个呢?这个要看你使用的基础框架:

对于内部基础服务来讲,我们一般都是使用SpringBoot默认的web模块:SpringMVC, 因为这个SpringMVC是基于Servlet模型的,在这里我们需要引入的是uaac-spring-boot-starter

对于网关服务,大体来讲分为两种:

  • 一种是基于Servlet模型的,如:Zuul,我们需要引入的是:uaac-spring-boot-starter,详见:SpringBoot环境集成
  • 一种是基于Reactor模型的,如:SpringCloud Gateway、ShenYu、Fizz Gateway 等等,我们需要引入的是:uaac-reactor-spring-boot-starter并且注册全局过滤器!,详见:在WebFlux环境集成

注:切不可直接在一个项目里同时引入这两个依赖,否则会造成项目无法启动

另外,我们需要引入Redis集成包,因为我们的网关和子服务主要通过Redis来同步数据

xml
<!-- UAAC 整合 Redis (使用jackson序列化方式) -->
<dependency>
    <groupId>com.cpit.csc</groupId>
    <artifactId>uaac-dao-redis-jackson</artifactId>
    <version>${uaac.top.version}</version>
</dependency>
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>

详细参考:集成 Redis

插件

AOP注解鉴权


在 <注解式鉴权>章节,我们非常轻松的实现了注解鉴权, 但是默认的拦截器模式却有一个缺点,那就是无法在Controller层以外的代码使用进行校验

因此UAAC提供AOP插件,你只需在pom.xml里添加如下依赖,便可以在任意层级使用注解鉴权

xml
<!-- UAAC整合SpringAOP实现注解鉴权 -->
<dependency>
	<groupId>com.cpit.csc</groupId>
	<artifactId>uaac-spring-aop</artifactId>
	<version>${uaac.top.version}</version>
</dependency>
注意点:
  • 使用拦截器模式,只能把注解写在Controller层,使用AOP模式,可以将注解写在任意层级
  • 拦截器模式和AOP模式不可同时集成,否则会在Controller层发生一个注解校验两次的bug

临时Token认证


适用场景

在部分业务场景,我们需要一种临时授权的能力,即:一个token的有效期并不需要像登录有效期那样需要[七天、三十天],而是仅仅需要 [五分钟、半小时]

举个比较明显的例子:超链接邀请机制

比如说你在一个游戏中创建一个公会 (id=10014),现在你想邀请你的好朋友加入这个公会,在你点击 [邀请] 按钮时,系统为你生成一个连接:

xml
http://xxx.com/apply?id=10014

接着,你的好朋友点击这个链接,加入了你的工会

那么,系统是如何识别这个链接对应的工会是10014呢?很明显,我们可以观察出,这个链接的尾部有个id参数值为10014,这便是系统识别的关键

此时你可能眉头一紧,就这么简单?那我如果手动更改一下尾部的参数改成10015,然后我再一点,岂不是就可以偷偷加入别人的工会了?

你想的没错,如果这个游戏的架构设计者采用上述方案完成功能的话,这个邀请机制就轻松的被你攻破了

但是很明显,正常的商业项目一般不会拉跨到这种地步,比较常见的方案是,对这个公会id做一个token映射,最终你看到链接一般是这样的:

xml
http://xxx.com/apply?token=oEwQBnglXDoGraSJdGaLooPZnGrk

后面那一串字母是乱打出来的,目的是为了突出它的随机性,即:使用一个随机的token来代替明文显示真正的数据

在用户点击这个链接之后,服务器便可根据这个token解析出真正公会id (10014) ,至于伪造?全是随机的你怎么伪造?你又不知道10015会随机出一个什么样的Token

而且为了安全性,这个token的有效期一般不会太长,给你预留五分钟、半小时的时间足够你点击它即可

相关API

[uaac-temp临时认证模块] 已内嵌到核心包,无需引入其它依赖即可使用

java
// 根据 value 创建一个 token 
String token = CpTempUtil.createToken("10014", 200);

// 解析 token 获取 value,并转换为指定类型 
String value = CpTempUtil.parseToken(token, String.class);

// 获取指定 token 的剩余有效期,单位:秒 
CpTempUtil.getTimeout(token);

// 删除指定 token
CpTempUtil.deleteToken(token);
集成jwt

提到 [临时Token认证],你是不是想到一个专门干这件事的框架?对,就是JWT!

[uaac-temp] 模块允许以JWT作为逻辑内核完成工作,你只需要引入以下依赖,所有上层API保持不变

xml
<dependency>
	<groupId>com.cpit.csc</groupId>
	<artifactId>uaac-temp-jwt</artifactId>
	<version>${uaac.top.version}</version>
</dependency>

并在配置文件中配置上jwt秘钥 (必填!)

java
uaac: 
	# uaac-temp-jwt 模块的秘钥 (随便乱摁几个字母就行了) 
	jwt-secret-key: JfdDSgfCmPsDfmsAaQwnXk

Quick-Login快速登录认证


解决什么问题

UAAC-Quick-Login 可以为一个系统快速的、零代码 注入一个登录页面

试想一下,假如我们开发了一个非常简单的小系统,比如说:服务器性能监控页面, 我们将它部署在服务器上,通过访问这个页面,我们可以随时了解服务器性能信息,非常方便

然而,这个页面方便我们的同时,也方便了一些不法的攻击者,由于这个页面毫无防护的暴露在公网中,任何一台安装了浏览器的电脑都可以随时访问它!

为此,我们必须给这个系统加上一个登录认证,只有知晓了后台密码的人员才可以进行访问

细细想来,完成这个功能你需要:

  1. 编写前端登录页面,手写各种表单样式
  2. 寻找合适的ajax类库,jQueryAxios?还是直接前后台不分离?
  3. 寻找合适的模板引擎,比如jspThymeleafFreeMarkerVelocity……选哪个呢?
  4. 处理后台各种拦截认证逻辑,前后台接口对接
  5. 你可能还会遇到令人头痛欲裂的模板引擎中ContextPath处理
  6. ……

你马上就会发现,写个监控页你一下午就可以搞定,然而这个登录页你却可能需要花上两三天的时间,这是一笔及其不划算的时间浪费

那么现在你可能就会有个疑问,难道就没有什么方法给我的小项目快速增加一个登录功能吗?

UAAC-Quick-Login便是为了解决这个问题!

适用场景

UAAC-Quick-Login 旨在用最小的成本为项目增加一个登录认证功能

  • 简单:只需要引入一个依赖便可为系统注入登录功能,快速、简单、零代码!
  • 不可定制:由于登录页面不可定制,所以UAAC-Quick-Login非常不适合普通项目的登录认证模块,STQL也无意去解决所有项目的登录认证模块

UAAC-Quick-Login的定位是这样的场景:你的项目需要一个登录认证功能、这个认证页面可以不华丽、可以烂,但是一定要有,同时你又不想花费太多的时间浪费在登录页面上, 那么你便可以尝试一下UAAC-Quick-Login

集成步骤

首先我们需要创建一个SpringBoot的demo项目,比如:uaac-demo-quick-login

1、添加pom依赖
xml
<!-- UAAC-Quick-Login 插件 -->
<dependency>
	<groupId>com.cpit.csc</groupId>
	<artifactId>uaac-quick-login</artifactId>
	<version>${uaac.top.version}</version>
</dependency>
2、启动类
java
@SpringBootApplication
public class UaacQuickDemoApplication {
	public static void main(String[] args) {
		SpringApplication.run(UaacQuickDemoApplication.class, args);
		
		System.out.println("\n------ 启动成功 ------");
		System.out.println("name: " + CpQuickManager.getConfig().getName());
		System.out.println("pwd:  " + CpQuickManager.getConfig().getPwd());
	}
}
3、新建测试Controller
java
/**
 * 测试专用Controller 
 */
@RestController
public class TestController {
	// 浏览器访问测试: http://localhost:8081
	@RequestMapping({"/", "/index"})
	public String index() {
		String str = "<br />"
				+ "<h1 style='text-align: center;'>资源页 (登录后才可进入本页面) </h1>"
				+ "<hr/>"
				+ "<p style='text-align: center;'> UAAC " + CpConsts.VERSION_NO + " </p>";
		return str;
	}
}
测试访问

启动项目,使用浏览器访问:http://localhost:8081,首次访问时,由于处于未登录状态,会被强制进入登录页面

使用默认账号:uaac / 123456进行登录,会看到资源页面

可配置信息

你可以在yml中添加如下配置 (所有配置都是可选的)

java
### UAAC-Quick-Login 配置
uaac: 
	# 登录账号
	name: uaac
	# 登录密码
	pwd: 123456
	# 是否自动随机生成账号密码 (此项为true时, name与pwd失效)
	auto: false
	# 是否开启全局认证(关闭后将不再强行拦截) 
	auth: true
	# 登录页标题
	title: UAAC 登录
	# 是否显示底部版权信息 
	copr: true
    # 指定拦截路径 
    # include: /**
    # 指定排除路径
    # exclude: /sss,/fff

**注:**示例源码在/uaac-demo/uaac-demo-quick-login目录下,可结合源码查看学习

使用独立jar包运行

使用uaac-quick-login只需引入一个依赖即可为系统注入登录模块,现在我们更进一步,将这个项目打成一个独立的jar包

通过这个jar包,我们可以方便的部署任意静态网站!做到真正的零编码注入登录功能。

打包步骤

1、首先将 uaac-demo-quick-login 模块添加到顶级父模块的<modules>节点中

xml
<!-- 所有模块 -->
<modules>
	<module>uaac-core</module>
	<module>uaac-starter</module>
	<module>uaac-plugin</module>
	<module>uaac-demo\uaac-demo-quick-login</module>
</modules>

2、在项目根目录进入cmd执行打包命令

cmd
mvn clean package

3、进入\uaac-demo\uaac-demo-quick-login\target 文件夹,找到打包好的jar文件

cmd
uaac-demo-quick-login-0.0.1-SNAPSHOT.jar

4、我们将其重命名为uaac-quick-dist.jar,现在这个jar包就是我们的最终程序,我们在这个\target目录直接进入cmd,执行如下命令启动jar包

cmd
java -jar uaac-quick-dist.jar

5、测试访问,根据控制台输出提示,我们使用浏览器访问测试: http://localhost:8080

如果可以进入登录界面,则代表打包运行成功 当然仅仅运行成功还不够,下面我们演示一下如何使用这个jar包进行静态网站部署

所有功能示例
Case 1. 指定静态资源路径
cmd
java -jar uaac-quick-dist.jar --uaac.dir file:E:\www

使用dir参数指定E:\www目录作为资源目录进行部署 (现在我们可以通过浏览器访问E:\www目录下的文件了!)

####### Case 2. 指定登录名与密码

cmd
java -jar uaac-quick-dist.jar --uaac.name=zhang --uaac.pwd=zhang123

现在,默认的账号uaac/123456将被废弃,而是使用zhang/zhang123进行账号校验

Case 3. 指定其自动生成账号密码
cmd
java -jar uaac-quick-dist.jar --uaac.auto=true

每次启动时随机生成账号密码(会在启动成功时打印到控制台上)

Case 4. 指定登录页的标题
cmd
java -jar uaac-quick-dist.jar --uaac.title="XXX 系统登录"
Case 5. 关闭账号校验,仅作为静态资源部署使用
cmd
java -jar uaac-quick-dist.jar --uaac.auth=false
Case 6. 指定启动端口(默认8080)
cmd
java -jar uaac-quick-dist.jar --server.port=80 

注:所有参数可组合使用

使用SpringBoot默认资源路径

SpringBoot默认开放了一些路径作为资源目录,比如classpath:/static/, 怎么使用呢?我们只需要在jar包同目录创建一个\static文件夹,将静态资源文件复制到此目录下,然后启动jar包即可访问

同时,我们还可以在jar包同目录创建yml配置文件,来覆盖jar包内的yml配置,如下图所示:

例如如上目录中/static中有一个1.jpg文件,我们启动jar包后访问http://localhost:8080/1.jpg即可查看到此文件,这是Springboot自带的功能,在此不再赘述

Alone独立Redis插件


UAAC默认的Redis集成方式会把权限数据和业务缓存放在一起,但在部分场景下我们需要将他们彻底分离开来,比如:

搭建两个Redis服务器,一个专门用来做业务缓存,另一台专门存放UAAC权限数据

要将UAAC的数据单独抽离出来很简单,你只需要为UAAC单独配置一个Redis连接信息即可


1、首先引入Alone-Redis依赖
xml
<!-- UAAC插件:权限缓存与业务缓存分离 -->
<dependency>
	<groupId>com.cpit.csc</groupId>
	<artifactId>uaac-alone-redis</artifactId>
	<version>${uaac.top.version}</version>
</dependency>
2、然后在application.yml中增加配置
yml
### UAAC配置
uaac: 
	# Token名称
	token-name: uaac-token
	# Token有效期
	timeout: 2592000
	# Token风格
	token-style: uuid
	
	# 配置 UAAC 单独使用的 Redis 连接 
	alone-redis: 
		# Redis数据库索引(默认为0)
		database: 2
		# Redis服务器地址
		host: 127.0.0.1
		# Redis服务器连接端口
		port: 6379
		# Redis服务器连接密码(默认为空)
		password: 
		# 连接超时时间
		timeout: 10s

spring: 
	# 配置业务使用的 Redis 连接 
	redis: 
		# Redis数据库索引(默认为0)
		database: 0
		# Redis服务器地址
		host: 127.0.0.1
		# Redis服务器连接端口
		port: 6379
		# Redis服务器连接密码(默认为空)
		password: 
		# 连接超时时间
		timeout: 10s

具体可参考示例:uaac/uaac-demo/uaac-demo-alone-redis/src/main/resources/application.yml。

3、测试

新建Controller测试一下

java
@RestController
@RequestMapping("/test/")
public class TestController {

	@Autowired
	StringRedisTemplate stringRedisTemplate;
	
	// 测试UAAC缓存
	@RequestMapping("login")
	public AjaxJson login(@RequestParam(defaultValue="10001") String id) {
		System.out.println("--------------- 测试UAAC缓存");
		StpUtil.login(id);	
		return AjaxJson.getSuccess();
	}
	
	// 测试业务缓存
	@RequestMapping("test")
	public AjaxJson test() {
		System.out.println("--------------- 测试业务缓存");
		stringRedisTemplate.opsForValue().set("hello", "Hello World");
		return AjaxJson.getSuccess();
	}
	
}

分别访问两个接口,观察Redis中增加的数据

测试完毕!

持久层扩展


对于权限框架来讲,最容易碰到的扩展点便是数据存储方式,为了方便对接不同的缓存中间件,UAAC将所有数据持久化操作抽象到CpTokenDao接口, 开发者要对接不同的平台只需要实现此接口即可,接口签名:uaac/uaac-core/src/main/java/com/cpit/csc/uaac/dao/CpTokenDao.java

框架已提供的集成包包括:

  • 默认方式:储存在内存中,位于core核心包
  • uaac-dao-redis:Redis集成包,使用jdk默认序列化方式
  • uaac-dao-redis-jackson:Redis集成包,使用jackson序列化方式

有关Redis集成,详细参考:<集成Redis>。

和 Thymeleaf 集成

本插件的作用是让我们可以在 Thymeleaf 页面中使用 UAAC 相关API,俗称 —— 标签方言。


1、引入依赖

首先我们确保项目已经引入 Thymeleaf 依赖,然后在此基础上继续添加:

xml
<!-- 在 thymeleaf 标签中使用 UAAC -->
<dependency>
	<groupId>com.cpit.csc</groupId>
	<artifactId>uaac-dialect-thymeleaf</artifactId>
	<version>${uaac.top.version}</version>
</dependency>
2、注册标签方言对象

在 CpConfigure 配置类中注册 Bean

java
@Configuration
public class CpConfigure {
	// UAAC 标签方言 (Thymeleaf版)
	@Bean
	public CpDialect getCpDialect() {
		return new CpDialect();
	}
}
3、使用标签方言

然后我们就可以愉快的使用在 Thymeleaf 页面中使用标签方言了

3.1、登录判断
html
<h2>标签方言测试页面</h2>
<p>
	登录之后才能显示:
	<span uaac:login>value</span>
</p>
<p>
	不登录才能显示:
	<span uaac:notLogin>value</span>
</p>
3.2、角色判断
html
<p>
	具有角色 admin 才能显示:
	<span uaac:hasRole="admin">value</span>
</p>
<p>
	同时具备多个角色才能显示:
	<span uaac:hasRoleAnd="admin, ceo, cto">value</span>
</p>
<p>
	只要具有其中一个角色就能显示:
	<span uaac:hasRoleOr="admin, ceo, cto">value</span>
</p>
<p>
	不具有角色 admin 才能显示:
	<span uaac:lackRole="admin">value</span>
</p>
3.3、权限判断
html
<p>
	具有权限 user-add 才能显示:
	<span uaac:hasPermission="user-add">value</span>
</p>
<p>
	同时具备多个权限才能显示:
	<span uaac:hasPermissionAnd="user-add, user-delete, user-get">value</span>
</p>
<p>
	只要具有其中一个权限就能显示:
	<span uaac:hasPermissionOr="user-add, user-delete, user-get">value</span>
</p>
<p>
	不具有权限 user-add 才能显示:
	<span uaac:lackPermission="user-add">value</span>
</p>
4、调用 UAAC 相关API

以上的标签方言,可以满足我们大多数场景下的权限判断,然后有时候我们依然需要更加灵活的在页面中调用 UAAC 框架API

首先在 CpConfigure 配置类中为 Thymeleaf 配置全局对象:

java
public class CpConfigure{
	// ... 其它代码
	
	// 为 Thymeleaf 注入全局变量,以便在页面中调用 UAAC 的方法 
	@Autowired
	private void configureThymeleafStaticVars(ThymeleafViewResolver viewResolver) {
		viewResolver.addStaticVariable("stp", StpUtil.stpLogic);
	}
}

然后我们就可以在页面上调用 StpLogic 的 API 了,例如:

html
<p>调用 StpLogic 方法调用测试</p>
<p th:if="${stp.isLogin()}">
	从CpSession中取值:
	<span th:text="${stp.getSession().get('name')}"></span>
</p>

和 jwt 集成

本插件的作用是让 UAAC 和 jwt 做一个整合。


1、引入依赖

首先在项目已经引入 UAAC 的基础上,继续添加:

xml
<!-- UAAC 整合 jwt -->
<dependency>
	<groupId>com.cpit.csc</groupId>
	<artifactId>uaac-jwt</artifactId>
	<version>${uaac.top.version}</version>
</dependency>
2、配置秘钥

application.yml 配置文件中配置 jwt 生成秘钥:

yml
uaac:
	# jwt秘钥 
	jwt-secret-key: asdasdasifhueuiwyurfewbfjsdafjk

注:为了安全起见请不要直接复制示例这个字符串(随便按几个字符就好了)

3、注入jwt实现

根据不同的整合规则,插件提供了三种不同的模式,你需要 选择其中一种 注入到你的项目中

Style 模式

Style 模式:Token 风格替换

java
@Configuration
public class CpTokenConfigure {
    // UAAC 整合 jwt (Style模式)
	@Bean
    public StpLogic getStpLogicJwt() {
    	return new StpLogicJwtForStyle();
    }
}
Mix 模式

Mix 模式:混入部分逻辑

java
@Configuration
public class CpTokenConfigure {
    // UAAC 整合 jwt (Style模式)
	@Bean
    public StpLogic getStpLogicJwt() {
    	return new StpLogicJwtForMix();
    }
}
Stateless模式

Stateless 模式:服务器完全无状态

java
@Configuration
public class CpTokenConfigure {
    // UAAC 整合 jwt (Style模式)
	@Bean
    public StpLogic getStpLogicJwt() {
    	return new StpLogicJwtForStateless();
    }
}
4、开始使用

然后我们就可以像之前一样使用 UAAC 了

java
/**
 * 登录测试 
 */
@RestController
@RequestMapping("/acc/")
public class LoginController {

    // 测试登录
    @RequestMapping("login")
    public CpResult login() {
		StpUtil.login(10001);
        return CpResult.ok("登录成功");
    }

    // 查询登录状态
    @RequestMapping("isLogin")
    public CpResult isLogin() {
        return CpResult.ok("是否登录:" + StpUtil.isLogin());
    }

    // 测试注销
    @RequestMapping("logout")
    public CpResult logout() {
        StpUtil.logout();
        return CpResult.ok();
    }

}

访问上述接口,观察Token生成的样式

java
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJsb2dpbklkIjoiMTAwMDEiLCJybiI6IjZYYzgySzBHVWV3Uk5NTTl1dFdjbnpFZFZHTVNYd3JOIn0.F_7fbHsFsDZmckHlGDaBuwDotZwAjZ0HB14DRujQfOQ
5、不同模式策略对比

注入不同模式会让框架具有不同的行为策略,以下是三种模式的差异点(为方便叙述,以下比较以同时引入 jwt 与 Redis 作为前提):

功能点Style 模式Mix 模式Stateless 模式
Token风格jwt风格jwt风格jwt风格
登录数据存储Redis中Token中Token中
Session存储Redis中Redis中无Session
注销下线前后端双清数据前后端双清数据前端清除数据
踢人下线API支持不支持不支持
登录认证支持支持支持
角色认证支持支持支持
权限认证支持支持支持
timeout 有效期支持支持支持
activity-timeout 有效期支持支持不支持
id反查Token支持支持不支持
会话管理支持部分支持不支持
注解鉴权支持支持支持
账号封禁支持支持不支持
身份切换支持支持支持
二级认证支持支持支持
模式总结Token风格替换jwt 与 Redis 逻辑混合完全舍弃Redis,只用jwt

和 Dubbo 集成

本插件的作用是让 UAAC 和 Dubbo 做一个整合。


先说说要解决的问题

在 Dubbo 的整个调用链中,代码被分为 Consumer 端和 Provider 端,为方便理解我们可以称其为 [调用端][被调用端]

RPC 模式的调用,可以让我们像调用本地方法一样完成服务通信,然而这种便利下却隐藏着两个问题:

  • 上下文环境的丢失。
  • 上下文参数的丢失。

这种问题作用在 UAAC 框架上就是,在 [ 被调用端 ] 调用 UAAC 相关API会抛出异常:无效上下文

所以本插件的目的也就是解决上述两个问题:

  • 在 [ 被调用端 ] 提供以 Dubbo 为基础的上下文环境
  • 在 RPC 调用时将 Token 传递至 [ 被调用端 ],同时在调用结束时将 Token 回传至 [ 调用端 ]。
引入插件

在项目已经引入 Dubbo 的基础上,继续添加依赖(Consumer 端和 Provider 端都需要引入):

xml
<!-- UAAC 整合 Dubbo -->
<dependency>
	<groupId>com.cpit.csc</groupId>
	<artifactId>uaac-context-dubbo</artifactId>
	<version>${uaac.top.version}</version>
</dependency>

然后我们就可以愉快的做到以下事情:

  1. 在 [ 被调用端 ] 安全的调用 UAAC 相关 API。
  2. 在 [ 调用端 ] 登录的会话,其登录状态可以自动传递到 [ 被调用端 ] 。
  3. 在 [ 被调用端 ] 登录的会话,其登录状态也会自动回传到 [ 调用端 ] 。

但是我们仍具有以下限制:

  1. [ 调用端 ] 与 [ 被调用端 ] 的 CpStorage 数据无法互通。
  2. [ 被调用端 ] 执行的 CpResponse.setHeader()setStatus() 等代码无效。

应该合理避开以上 API 的使用。

RPC调用鉴权

在之前的 <Id-Token> 章节,我们演示了基于 Feign 的 RPC 调用鉴权,下面我们演示一下在 Dubbo 中如何集成 Id-Token 模块。

其实思路和 Feign 模式一致,在 [ 调用端 ] 追加 Id-Token 参数,在 [ 被调用端 ] 校验这个 Id-Token 参数:

  • 校验通过:调用成功。
  • 校验不通过:通过失败,抛出异常。

我们有两种方式完成整合。

方式一、使用配置

直接在 application.yml 配置即可:

yml
uaac: 
	# 打开 RPC 调用鉴权 
	check-id-token: true
方式二、自建 Dubbo 过滤器校验

1、在 [ 调用端 ] 的 \resources\META-INF\dubbo\ 目录新建 org.apache.dubbo.rpc.Filter 文件

html
dubboConsumerFilter=com.example.DubboConsumerFilter

新建 DubboConsumerFilter.java 过滤器

java
package com.example;

import org.apache.dubbo.common.constants.CommonConstants;
import org.apache.dubbo.common.extension.Activate;
import org.apache.dubbo.rpc.*;

import com.cpit.csc.uaac.id.CpIdUtil;

/**
 * UAAC 整合 Dubbo Consumer端过滤器 
 */
@Activate(group = {CommonConstants.CONSUMER}, order = -10000)
public class DubboConsumerFilter implements Filter {

	@Override
	public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
		
		// 追加 Id-Token 参数 
		RpcContext.getContext().setAttachment(CpIdUtil.ID_TOKEN, CpIdUtil.getToken()); 
		
		// 开始调用
		return invoker.invoke(invocation);
	}

}

2、在 [ 被调用端 ] 的 \resources\META-INF\dubbo\ 目录新建 org.apache.dubbo.rpc.Filter 文件

html
dubboProviderFilter=com.example.DubboProviderFilter

新建 DubboProviderFilter.java 过滤器

java
package com.example;

import org.apache.dubbo.common.constants.CommonConstants;
import org.apache.dubbo.common.extension.Activate;
import org.apache.dubbo.rpc.*;

import com.cpit.csc.uaac.id.CpIdUtil;

/**
 * UAAC 整合 Dubbo Provider端过滤器 
 */
@Activate(group = {CommonConstants.PROVIDER}, order = -10000)
public class DubboProviderFilter implements Filter {

	@Override
	public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
		
		// 取出 Id-Token 进行校验 
		String idToken = invocation.getAttachment(CpIdUtil.ID_TOKEN);
		CpIdUtil.checkToken(idToken);
		
		// 开始调用
		return invoker.invoke(invocation);
	}

}

然后我们就可以进行安全的 RPC 调用了,不带有 Id-Token 参数的调用都会抛出异常,无法调用成功。

UAAC 插件开发指南


插件,从字面意思理解就是可拔插的组件,作用是在不改变 UAAC 现有架构的情况下,替换或扩展一部分底层代码逻辑。

为 UAAC 开发插件非常简单,以下是几种可用的方法:

  • 自定义全局策略。
  • 更改全局组件实现。
  • 实现自定义CpContext。
  • 其它自由扩展。

下面依次介绍这几种方式。

1、自定义全局策略

UAAC 将框架的一些关键逻辑抽象出一个统一的概念 —— 策略,并统一定义在 CpStrategy 中,参考源码CpStrategy.java 。

CpStrategy 的每一个函数都可以单独重写,以 “自定义Token生成策略” 这一需求为例:

java
// 重写 Token 生成策略 
CpStrategy.me.createToken = (loginId, loginType) -> {
	return CpFoxUtil.getRandomString(60);    // 随机60位长度字符串
};

就像变量的重新赋值一样,我们只需重新指定一个新的策略函数,即可自定义 Token 生成的逻辑。

2、更改全局组件实现

UAAC 大部分全局组件都定义在 CpManager 之上(参见CpManager.java), 我们只需要更改组件的实现类即可。以 临时令牌认证 模块举例

1、先自定义一个实现类
java
/**
 * 临时认证模块 自定义实现 
 */
public class MyCpTemp implements CpTempInterface {

	@Override
	public String createToken(Object value, long timeout) {
		System.out.println("------- 自定义一些逻辑 createToken ");
		return CpTempInterface.super.createToken(value, timeout);
	}
	
	@Override
	public Object parseToken(String token) {
		System.out.println("------- 自定义一些逻辑 parseToken ");
		return CpTempInterface.super.parseToken(token);
	}
	
}
2、将自定义实现类绑定在 CpManager 上
java
// 注入 
CpManager.setCpTemp(new MyCpTemp());

以上是手动注入方式,如果你是 Spring 的 IOC 环境,则直接在 MyCpTemp 实现类加上 @Component 注解即可。

3、开始测试:
java
// 根据 value 创建一个 token 
String token = CpTempUtil.createToken("10014", 120);
System.out.println("生成的Token为:" + token);

Object value = CpTempUtil.parseToken(token);
System.out.println("将Token解析后的值为:" + value);

观察控制台输出,检验自定义实现类是否注入成功:

3、实现自定义CpContext

CpContext 是对接不同框架的上下文接口,注入流程和第二步类似,篇幅限制,可参考<自定义 CpContext 指南>一节。

4、其它自由扩展

这种方式就无需注入什么全局组件替换内部实现了,你可以在 UAAC 的基础之上封装任何代码,进行功能扩展。

5、练练手

熟悉了插件开发流程,下面的 [ 待开发插件列表 ] 或许可以给你提供一个练手的方向。

CpContext 实现:
插件功能状态
uaac-solon-starterUAAC 与 Solon 的整合已完成
uaac-jfinal-starterUAAC 与 JFinal 的整合待开发
uaac-hasor-starterUAAC 与 Hasor 的整合待开发
标签方言:
插件功能状态
uaac-dialect-thymeleafUAAC 与 thymeleaf 的整合已完成
uaac-dialect-freemarkerUAAC 与 freemarker 的整合待开发
uaac-dialect-jspUAAC 与 jsp 的整合待开发
uaac-dialect-velocityUAAC 与 velocity 的整合待开发
uaac-dialect-beetlUAAC 与 beetl 的整合待开发
持久层扩展:
插件功能状态
uaac-dao-redisUAAC 与 Redis 的整合已完成
uaac-dao-memcachedUAAC 与 memcached 的整合待开发
其它:

任何你认为有价值的功能代码,都可以扩展为插件。

附录

常用类、方法

本篇介绍UAAC中一些常用的全局对象、类


CpManager

CpManager 负责管理 UAAC 所有运行时对象

java
CpManager.getConfig();              // 获取全局配置对象 
CpManager.getCpTokenDao();          // 获取数据持久化对象 
CpManager.getStpInterface();        // 获取权限认证对象 
CpManager.getCpAction();       // 获取框架行为对象
CpManager.getCpContext();      // 获取上下文处理对象
CpManager.getCpListener();     // 获取侦听器对象 
CpManager.getCpTemp();              // 获取临时令牌认证模块对象 
CpManager.getStpLogic("type");      // 获取指定账号类型的StpLogic对象 
CpHolder

UAAC上下文持有类,通过此类快速获取当前环境的相关对象

java
CpHolder.getRequest();           // 获取当前请求的 [Request] 对象 
CpHolder.getResponse();          // 获取当前请求的 [Response] 对象 
CpHolder.getStorage();           // 获取当前请求的 [存储器] 对象
CpRouter

路由匹配工具类,详见:路由拦截式鉴权

CpFoxUtil

UAAC内部工具类,包含一些工具方法

java
CpFoxUtil.printCp();           // 打印 UAAC 版本字符画
CpFoxUtil.getRandomString(8);       // 生成指定长度的随机字符串
CpFoxUtil.isEmpty(str);             // 指定字符串是否为null或者空字符串
CpFoxUtil.getMarking28();           // 以当前时间戳和随机int数字拼接一个随机字符串
CpFoxUtil.formatDate(date);         // 将日期格式化为yyyy-MM-dd HH:mm:ss字符串
CpFoxUtil.searchList();             // 从集合里查询数据
CpFoxUtil.vagueMatch(patt, str);    // 字符串模糊匹配
CpConfigFactory

配置对象工厂类,通过此类你可以方便的根据properties配置文件创建一个配置对象

1、首先在项目根目录,创建一个配置文件:uaac.properties

java
### token名称 (同时也是cookie名称)
tokenName=uaac-token
### token有效期,单位s 默认30天, -1代表永不过期 
timeout=2592000
### token临时有效期 (指定时间内无操作就视为token过期) 单位:
activityTimeout=-1
### 是否允许同一账号并发登录 (为true时允许一起登录, 为false时新登录挤掉旧登录) =-1
isConcurrent=true
### 在多人登录同一账号时,是否共用一个token (为true时所有登录共用一个token, 为false时每次登录新建一个token) 
isShare=true
### token风格
isLog=false

2、然后使用以下代码获取配置对象

java
// 设置配置文件地址 
CpConfigFactory.configPath = "uaac.properties";

// 获取配置信息到 config 对象
CpConfig config = CpConfigFactory.createConfig();

// 注入到 CpManager 中
CpManager.setConfig(config);
SpringMVCUtil

SpringMVC操作的工具类,位于包:uaac-spring-boot-starter

java
SpringMVCUtil.getRequest();           // 获取本次请求的 request 对象 
SpringMVCUtil.getResponse();          // 获取本次请求的 response 对象 
CpReactorHolder & CpReactorSyncHolder

UAAC集成Reactor时的 ServerWebExchange 工具类,位于包:uaac-reactor-spring-boot-starter

java
// 异步方式获取 ServerWebExchange 对象 
CpReactorHolder.getContext().map(e -> {
	System.out.println(e);
});

// 同步方式获取 ServerWebExchange 对象 
ServerWebExchange e = CpReactorSyncHolder.getContext();
System.out.println(e);

常见问题排查

本篇整理大家经常提问的一些问题。


一、常见报错

报错:非Web上下文无法获取Request?

报错原因:UAAC 的部分 API 只能在 Web 上下文中调用,报这个错说明你调用 UAAC 的地方不在 Web 上下文中,请排查:

  1. 是否在 main 方法中调用了 UAAC 的API
  2. 是否在带有 @Async 注解的方法中调用了 UAAC 的API
  3. 是否在一些丢失web上下文的子线程中调用了 UAAC 的API,例如 MyBatis-PlusinsertFill 自动填充
  4. 是否在一些非 Http 协议的 RPC 框架中(例如 Dubbo)调用了 UAAC 的API
  5. 是否在 SpringBoot 启动初始化的方法中调用了 UAAC 的API,例如@PostConstruct

解决方案:先获取你想要的值,再把这个值当做一个参数传递到这些方法中,而不是直接从方法内调用 UAAC 的API。

报错:未初始化任何有效上下文处理器?

报错原因:UAAC底层不能确认最终运行的web容器,所以抽象了 CpContext 接口,对接不同容器时需要注入不同的实现,通常这个注入工作都是框架自动完成的, 你只需要按照文档开始部分集成相应的依赖即可

如果报了这个错误,说明框架没有注入正确的上下文实现,请排查:

  1. 如果你的项目是微服务项目,请直接参考:微服务-依赖引入说明,如果是单体项目,请往下看:
  2. 请判断你的项目是 SpringMVC 环境还是 WebFlux 环境
  • 如果是 SpringMVC 环境就引入 uaac-spring-boot-starter 依赖,参考:在SpringBoot环境集成
  • 如果是 WebFlux 环境就引入 uaac-reactor-spring-boot-starter 依赖,参考:在WebFlux环境集成
  • 引入错误的依赖会导致CpContext初始化失败,抛出上述异常
  • 如果你还无法分辨你是哪个环境,就看你的 pom.xml 依赖,如果引入了spring-boot-starter-web就是SpringMVC环境,如果引入了 spring-boot-starter-webflux 就是WebFlux环境。……什么?你说你两个都引入了?那你的项目能启动成功吗?
  • 你说你两个包都没引入?那你为什么不引入一个呢?
  1. 如果是 WebFlux 环境而且正确引入了依赖,依然报错,请检查是否注册了全局过滤器,在 WebFlux 下这一步是必须的。
  2. 如果以上步骤排除无误后依然报错,请直接提 issues 或者加入QQ群求助。
报错:NotLoginException:xxx

这个错是说明调用接口的人没有通过登录认证,请注意通常异常提示语已经描述清楚了没有通过认证的具体原因,例如:没有提供Token、提供的Token是无效的、提供的Token已经过期……等等

请根据异常提示语以及报错位置进行排查,可参考:NotLoginException 场景值

加了注解进行鉴权认证,不生效?

注解鉴权功能默认关闭,两种方式任选其一进行打开:注册注解拦截器、集成AOP模块,参考:注解式鉴权, 如果已经打开仍然没有效果,加群说明一下复现步骤

有时候我不加 Token 也可以通过鉴权,请问是怎么回事?

可能是Cookie帮你自动传了,在浏览器或 Postman 中会自动维护Cookie模式,如不需要可以在配置文件:is-read-cookie: false,然后重启项目再测试一下

一个User对象存进Session后,再取出来时报错:无法从User类型转换成User类型?

群员亲测,当你打开热部署模式后,先存进去的对象,热刷新后再取出,会报错,关闭热刷新即可解决

Springboot环境下采用自定义拦截器排除了某个路径仍然被拦截了?

可能是404了,SpringBoot环境下如果访问接口404后,会被转发到/error,然后被再次拦截。

如果不是404,可以先打印一下访问的路由,因为后端拦截的未必是你前端访问的这个path,先获取到具体path再仔细分析。

我配置了 active-timeout 值,但是当我每次续签时 Redis 中的 ttl 并没有更新,是不是 bug 了?

不更新是正常现象,active-timeout不是根据 ttl 计算的,是根据value值计算的,value 记录的是该 Token 最后访问系统的时间戳, 每次验签时用:当前时间 - 时间戳 > active-timeout,来判断这个 Token 是否已经超时

集成 Redis 后,明明 Redis 中有值,却还是提示无效Token?

根据以往的处理经验,发生这种情况 90% 的概率是因为你找错了Redis,即:代码连接的Redis和你用管理工具看到的Redis并不是同一个。

你可能会问:我看配置文件明明是同一个啊?

我的回答是:别光看配置文件,不一定准确,在启动时直接执行 CpManager.getCpTokenDao().set("name", "value", 100000);, 随便写入一个值,看看能不能根据你的预期写进这个Redis,如果能的才能证明 Redis 连接没问题,再进行下一步排查。

整合 Redis 时先选择了默认jdk序列化,后又改成 jackson 序列化,程序开始报错,SerializationException?

两者的序列化算法不一致导致的反序列化失败,如果要更改序列化方式,则需要先将 Redis 中历史数据清除,再做更新

集成 jwt 后为什么在 getSession 时提示 jwt has not session ?

jwt 的招牌便是无须借助服务端完成会话管理,如果集成jwt后再使用Session功能,那将又回到了传统Session模式,属于自断招牌,此种技术组合没有意义, 因此jwt集成模式不提供Session功能,如果需要Session功能,就不要集成jwt

二、常见疑问

登录方法需要我自己实现吗?

是的,不同于shiro等框架,UAAC不会在登录流程中强插一脚,开发者比对完用户的账号和密码之后,只需要调用StpUtil.login(id)通知一下框架即可

框架抛出的权限不足异常,我想根据自定义提示信息,可以吗?

可以,在全局异常拦截器里捕获NotPermissionException,可以通过getCode()获取没有通过认证的权限码,可以据此自定义返回信息

我的项目权限模型不是RBAC模型,很复杂,可以集成吗?

无论什么模型,只要能把一个用户具有的所有权限塞到一个List里返回给框架,就能集成

当我配置不并发登录时,每次登陆都会产生一个新的 Token,旧 Token 依然被保存在 Redis 中,框架为什么不删除呢?

首先,不删除旧 Token 的原因是为了在旧 Token 再次访问系统时提示他:已被顶下线。

而且这个 Token 不会永远留在 Redis 里,在其 TTL 到期后就会自动清除,如果你想让它立即消失,可以:

  • 方法一:配置文件把 is-concurrentis-share 都打开,这样每次登陆都会复用以前的旧 Token,就不会有废弃 Token 产生了。
  • 方法二:每次登录前把先调用注销方法,把这个账号的旧登录都给清除了。
  • 方法三:写一个定时任务查询Redis值进行删除。
我使用过滤器鉴权 or 全局拦截器鉴权,结果 Swagger 不能访问了,我应该排除哪些地址?

尝试加上排除 "/swagger-resources/**", "/webjars/**", "/v2/**", "/swagger-ui.html/**" ,"/doc.html/**","/error","/favicon.ico"

不同版本可能会有所不同,其实在前端摁一下 F12 看看哪个 url 报错排除哪个就行了(另附:注解鉴权是不需要排除的,因为 Swagger 本身也没有使用 UAAC 的注解)

CpRouter.match 有多个路径需要排除怎么办?

可以点进去源码看一下,CpRouter.match方法有多个重载,可以放一个集合, 例如: CpRouter.match(/**).notMatch("/login", "/reg").check(r -> StpUtil.checkLogin());

为什么StpUtil.login() 不能直接写入一个User对象?

StpUtil.login()只是为了给当前会话做个唯一标记,通常写入UserId即可,如果要存储User对象,可以使用StpUtil.getSession()获取Session对象进行存储。

前后台分离模式下和普通模式有何不同?

主要是失去了Cookie无法自动化保存和提交token秘钥,可以参考章节:前后台分离

前后台分离时,前端提交的 header 参数是叫 token 还是 uaac-token 还是 tokenName?

默认是uaac-token,如果想换一个名字,更改一下配置文件的tokenName即可。

权限可以做成动态的吗?

权限本来就是动态的,只有jwt那种模式才是非动态的

我不想让框架自动操作Cookie,怎么办?

在配置文件将isReadCookie值配置为false

怎么关掉每次启动时的字符画打印?

在配置文件将isPrint值配置为false

StpUtil.getSession()必须登录后才能调用吗?如果我想在用户未登录之前存储一些数据应该怎么办?

StpUtil.getSession()获取的是User-Session,必须登录后才能使用,如果需要在未登录状态下也使用Session功能,请使用Token-Session 步骤:先在配置文件里将tokenSessionCheckLogin配置为false,然后通过StpUtil.getTokenSession()获取Session

我只使用header来传输token,还需要打开Cookie模式吗?

不需要,如果只使用header来传输token,可以在配置文件关闭Cookie模式,例:isReadCookie=false

我想让用户修改密码后立即掉线重新登录,应该怎么做?

框架内置 [强制指定账号下线] 的APi,在执行修改密码逻辑之后调用此API即可: StpUtil.logout()

代码鉴权、注解鉴权、路由拦截鉴权,我该如何选择?

这个问题没有标准答案,这里只能给你提供一些建议,从鉴权粒度的角度来看:

  1. 路由拦截鉴权:粒度最粗,只能粗略的拦截一个模块进行权限认证
  2. 注解鉴权:粒度较细,可以详细到方法级,比较灵活
  3. 代码鉴权:粒度最细,不光可以控制到方法级,甚至可以if语句决定是否鉴权

So:从鉴权粒度的角度来看,需要针对一个模块鉴权的时候,就用路由拦截鉴权,需要控制到方法级的时候,就用注解鉴权,需要根据条件判断是否鉴权的时候,就用代码鉴权

UAAC的全局过滤器我应该怎么指定它的优先级呢?

为了保证相关组件能够及时初始化,框架默认给过滤器注册的优先级为-100,如果你想更改优先级,直接在注册过滤器的方法上加上 @Order(xxx) 即可覆盖框架的默认配置

框架名词解释

UAAC 无意发明任何晦涩概念提升逼格,但在处理 issues 、Q群解答时还是发现不少同学因为一些基本概念理解偏差导致代码出错, 所以整理本篇针对一些比较容易混淆的地方加以解释说明。

也希望各位同学在提交 issues、Q群提问之前充分阅读本篇文章,保证不要因为基本概念理解偏差,增加不必要的沟通成本。


几种 Token
  • token:指通过 StpUtil.login() 登录产生的身份令牌,用来维护用户登录状态,也称:uaac-token、会话Token。
  • temp-token:指通过 CpTempUtil.createToken() 临时验证模块产生的Token,也称:临时Token。
  • Access-Token:在 OAuth2 模块产生的身份令牌,也称:访问令牌、资源令牌。
  • Refresh-Token:在 OAuth2 模块产生的刷新令牌,也称:刷新令牌。
  • Id-Token:在 CpIdUtil 模块生成的Token令牌,用于提供子服务外网隔离功能。
两种过期时间:
  • timeout:会话 Token 的长久有效期。
  • activity-timeout:会话的临时有效期。

两者的差别详见:Token有效期详解

三种Session:
  • User-Session:框架为每个账号分配的 Session 对象,也称:账号Session。
  • Token-Session:框架为每个 Token 分配的 Session 对象,也称:令牌Session。
  • Custom-Session:以一个特定的值作为SessionId,来分配的 Session 对象,也称:自定义Session。

三者差别详见:Session模型详解

账号标识:
  • loginId:账号id,用来区分不同账号,通过 StpUtil.login(id) 来指定。
  • device:登录设备端,例如:PCAPP,通过 StpUtil.login(id, device) 来指定。
  • loginType:账号类型,用来区分不同体系的账号,如同一系统的 User账号Admin账号,详见:多账号认证
几种登录策略:
  • 单地登录:指同一时间只能在一个地方登录,新登录会挤掉旧登录,也可以叫:单端登录。
  • 多地登录:指同一时间可以在不同地方登录,新登录会和旧登录共存,也可以叫:多端登录。
  • 同端互斥登录:在同一类型设备上只允许单地点登录,在不同类型设备上允许同时在线,参考腾讯QQ的登录模式:手机和电脑可以同时在线,但不能两个手机同时在线。
  • 单点登录:在进入多个系统时,只需要登录一次即可。解决用户在不同系统间频繁登录的问题。
  • 同端多登录:指在一个终端可以同时登录多个账号。
  • 记住我模式:指在一个设备终端登录成功,该设备重启之后依然保持登录状态。
几种注销策略:
  • 单端注销:只在调用登录的一端注销。
  • 全端注销:一端注销,全端下线。
  • 单点注销:与单点登录对应,一个系统注销,所有系统一起下线。
几种鉴权方式:
  • 代码鉴权:在代码里直接调用 StpUtil.checkXxx 相关 API 进行鉴权。
  • 注解鉴权:在方法或类上添加 @CpCheckXxx 注解进行鉴权。
  • 路由拦截鉴权:在全局过滤器或拦截里通过:CpRouter.match() 拦截路由进行鉴权。

未登录场景详解

本篇介绍如何根据NotLoginException异常的场景值,来定制化处理未登录的逻辑 应用场景举例:未登录、被顶下线、被踢下线等场景需要不同方式来处理

何为场景值

在前面的章节中,我们了解到,在会话未登录的情况下尝试获取loginId会使框架抛出NotLoginException异常,而同为未登录异常却有五种抛出场景的区分

场景值对应常量含义说明
-1NotLoginException.NOT_TOKEN未能从请求中读取到token
-2NotLoginException.INVALID_TOKEN已读取到token,但是token无效
-3NotLoginException.TOKEN_TIMEOUT已读取到token,但是token已经过期
-4NotLoginException.BE_REPLACED已读取到token,但是token已被顶下线
-5NotLoginException.KICK_OUT已读取到token,但是token已被踢下线

那么,如何获取场景值呢?废话少说直接上代码:

java
// 全局异常拦截(拦截项目中的NotLoginException异常)
@ExceptionHandler(NotLoginException.class)
public AjaxJson handlerNotLoginException(NotLoginException nle, HttpServletRequest request, HttpServletResponse response)
		throws Exception {

	// 打印堆栈,以供调试
	nle.printStackTrace(); 
	
	// 判断场景值,定制化异常信息 
	String message = "";
	if(nle.getType().equals(NotLoginException.NOT_TOKEN)) {
		message = "未提供token";
	}
	else if(nle.getType().equals(NotLoginException.INVALID_TOKEN)) {
		message = "token无效";
	}
	else if(nle.getType().equals(NotLoginException.TOKEN_TIMEOUT)) {
		message = "token已过期";
	}
	else if(nle.getType().equals(NotLoginException.BE_REPLACED)) {
		message = "token已被顶下线";
	}
	else if(nle.getType().equals(NotLoginException.KICK_OUT)) {
		message = "token已被踢下线";
	}
	else {
		message = "当前会话未登录";
	}
	
	// 返回给前端
	return AjaxJson.getError(message);
}

注意:以上代码并非处理逻辑的最佳方式,只为以最简单的代码演示出场景值的获取与应用,大家可以根据自己的项目需求来定制化处理

Token有效期详解

UAAC 提供两种Token自动过期策略,分别是timeoutactivity-timeout,其详细用法如下:

timeout
  1. timeout代表Token的长久有效期,单位/秒,例如将其配置为 2592000 (30天),代表在30天后,Token必定过期,无法继续使用
  2. timeout无法续签,想要继续使用必须重新登录
  3. timeout的值配置为-1后,代表永久有效,不会过期
activity-timeout
  1. activity-timeout代表临时有效期,单位/秒,例如将其配置为 1800 (30分钟),代表用户如果30分钟无操作,则此Token会立即过期
  2. 如果在30分钟内用户有操作,则会再次续签30分钟,用户如果一直操作则会一直续签,直到连续30分钟无操作,Token才会过期
  3. activity-timeout的值配置为-1后,代表永久有效,不会过期,此时也无需频繁续签
关于activity-timeout的续签

如果activity-timeout配置了大于零的值,UAAC 会在登录时开始计时,在每次直接或间接调用getLoginId()时进行一次过期检查与续签操作。 此时会有两种情况:

  1. 一种是会话无操作时间太长,Token已经过期,此时框架会抛出NotLoginException异常(场景值=-3),
  2. 另一种则是会话在activity-timeout有效期内通过检查,此时Token可以成功续签
我可以手动续签吗?

可以! 如果框架的自动续签算法无法满足您的业务需求,你可以进行手动续签,UAAC 提供两个API供你操作:

  1. StpUtil.checkActivityTimeout(): 检查当前Token 是否已经[临时过期],如果已经过期则抛出异常
  2. StpUtil.updateLastActivityToNow(): 续签当前Token:(将 [最后操作时间] 更新为当前时间戳)

注意:在手动续签时,即使Token已经 [临时过期] 也可续签成功,如果此场景下需要提示续签失败,可采用先检查再续签的形式保证Token有效性

例如以下代码:

java
// 先检查是否已过期
StpUtil.checkActivityTimeout();
// 检查通过后继续续签
StpUtil.updateLastActivityToNow();

同时,你还可以关闭框架的自动续签(在配置文件中配置 autoRenew=false ),此时续签操作完全由开发者控制,框架不再自动进行任何续签操作

timeout与activity-timeout可以同时使用吗?

可以同时使用! 两者的认证逻辑彼此独立,互不干扰,可以同时使用。

Session模型详解


1、User-Session

提起Session,你脑海中最先浮现的可能就是 JSP 中的 HttpSession,它的工作原理可以大致总结为:

客户端每次与服务器第一次握手时,会被强制分配一个 [唯一id] 作为身份标识,注入到 Cookie 之中, 之后每次发起请求时,客户端都要将它提交到后台,服务器根据 [唯一id] 找到每个请求专属的Session对象,维持会话

这种机制简单粗暴,却有N多明显的缺点:

  1. 同一账号分别在PC、APP登录,会被识别为两个不相干的会话
  2. 一个设备难以同时登录两个账号
  3. 每次一个新的客户端访问服务器时,都会产生一个新的Session对象,即使这个客户端只访问了一次页面
  4. 在不支持Cookie的客户端下,这种机制会失效

UAAC Session可以理解为 HttpSession 的升级版:

  1. UAAC只在调用StpUtil.login(id)登录会话时才会产生Session,不会为每个陌生会话都产生Session,节省性能
  2. 在登录时产生的Session,是分配给账号id的,而不是分配给指定客户端的,也就是说在PC、APP上登录的同一账号所得到的Session也是同一个,所以两端可以非常轻松的同步数据
  3. UAAC支持Cookie、Header、body三个途径提交Token,而不是仅限于Cookie
  4. 由于不强依赖Cookie,所以只要将Token存储到不同的地方,便可以做到一个客户端同时登录多个账号

这种为账号id分配的Session,我们给它起一个合适的名字:User-Session,你可以通过如下方式操作它:

java
// 获取当前会话的 User-Session 
CpSession session = StpUtil.getSession();

// 从 User-Session 中读取、写入数据 
session.get("name");
session.set("name", "张三");

使用User-Session在不同端同步数据是非常方便的,因为只要 PC 和 APP 登录的账号id一致,它们对应的都是同一个Session, 举个应用场景:在PC端点赞的帖子列表,在APP端的点赞记录里也要同步显示出来

2、Token-Session

随着业务推进,我们还可能会遇到一些需要数据隔离的场景:

指定客户端超过两小时无操作就自动下线,如果两小时内有操作,就再续期两小时,直到新的两小时无操作

那么这种请求访问记录应该存储在哪里呢?放在 User-Session 里吗?

可别忘了,PC端和APP端可是共享的同一个 User-Session ,如果把数据放在这里, 那就意味着,即使用户在PC端一直无操作,只要手机上用户还在不间断的操作,那PC端也不会过期!

解决这个问题的关键在于,虽然两个设备登录的是同一账号,但是两个它们得到的token是不一样的, UAAC针对会话登录,不仅为账号id分配了User-Session,同时还为每个token分配了不同的Token-Session

不同的设备端,哪怕登录了同一账号,只要它们得到的token不一致,它们对应的 Token-Session 就不一致,这就为我们不同端的独立数据读写提供了支持:

java
// 获取当前会话的 Token-Session 
CpSession session = StpUtil.getTokenSession();

// 从 Token-Session 中读取、写入数据 
session.get("name");
session.set("name", "张三");
3、Custom-Session

除了以上两种Session,UAAC还提供了第三种Session,那就是:Custom-Session,你可以将其理解为:自定义Session

Custom-Session不依赖特定的 账号id 或者 token,而是依赖于你提供的SessionId:

java
// 获取指定key的 Custom-Session 
CpSession session = CpSessionCustomUtil.getSessionById("goods-10001");

// 从 Custom-Session 中读取、写入数据 
session.get("name");
session.set("name", "张三");

只要两个自定义Session的Id一致,它们就是同一个Session

4、Session模型结构图

三种Session创建时机:

  • User-Session: 指的是框架为每个 账号id 分配的 Session
  • Token-Session: 指的是框架为每个 token 分配的 Session
  • Custom-Session: 指的是以一个 特定的值 作为SessionId,来分配的 Session

假设三个客户端登录同一账号,且配置了不共享token,那么此时的Session模型是:

简而言之:

  • User-Session 以UserId为主,只要token指向的UserId一致,那么对应的Session对象就一致
  • Token-Session 以token为主,只要token不同,那么对应的Session对象就不同
  • Custom-Session 以特定的key为主,不同key对应不同的Session对象,同样的key指向同一个Session对象

TokenInfo参数详解

token信息Model: 用来描述一个token的常用参数

js
{
	"code": 200,
	"msg": "ok",
	"data": {
		"tokenName": "uaac-token",           // token名称
		"tokenValue": "e67b99f1-3d7a-4a8d-bb2f-e888a0805633",      // token值
		"isLogin": true,                  // 此token是否已经登录
		"loginId": "10001",               // 此token对应的LoginId,未登录时为null
		"loginType": "login",              // 账号类型标识
		"tokenTimeout": 2591977,          // token剩余有效期 (单位: 秒)
		"sessionTimeout": 2591977,        // User-Session剩余有效时间 (单位: 秒)
		"tokenSessionTimeout": -2,        // Token-Session剩余有效时间 (单位: 秒)
		"tokenActivityTimeout": -1,       // token剩余无操作有效时间 (单位: 秒)
		"loginDevice": "default-device"   // 登录设备标识
	},
}

解决反向代理 uri 丢失的问题


使用 request.getRequestURL() 可获取当前程序所在外网的访问地址,在 UAAC 中,其 CpHolder.getRequest().getUrl() 也正是借助此API完成, 有很多模块都用到了这个能力,比如SSO单点登录。

我们可以使用如下代码测试此API

java
// 显示当前程序所在外网的都访问地址
@RequestMapping("test")
public String test() {
	return "您访问的是:" + CpHolder.getRequest().getUrl();
}

从浏览器访问此接口,我们可以看到:

此 API 在本地开发时一般可以正常工作,然而如果我们在部署时使用 Nginx 做了一层反向代理后,其最终结果可能会和我们预想的有一点偏差:

不仅是 Nginx,所有包含路由转发的地方都有可能导致上述丢失 uri 的现象,解决方案也很简单,既然程序无法自动识别,我们改成手动获取即可,UAAC 提供两个方案:

方案一:Nginx转发时追加 header 参数
1、首先在 Nginx 代理转发的地方增加参数

重点是这一句:proxy_set_header Public-Network-URL http://$http_host$request_uri;

2、在程序中新增类 CustomCpContextForSpring.java,重写获取uri的逻辑
java
@Primary
@Component
public class CustomCpContextForSpring extends CpContextForSpring {
	
	@Override
	public CpRequest getRequest() {
		return new CpRequestForServlet(SpringMVCUtil.getRequest()) {
			@Override
			public String getUrl() {
				if(request.getHeader("Public-Network-URL") != null) {
					return request.getHeader("Public-Network-URL");
				}
				return request.getRequestURL().toString();
			}
		};
	}

}

其它逻辑保持不变,框架即可正确获取 uri 地址

注意:步骤一与步骤二需要同步存在,否则可能有前端假传 header 参数造成安全问题

方案二:直接在yml中配置当前项目的网络访问地址

application.yml 中增加配置:

yml
uaac: 
    # 配置当前项目的网络访问地址
    curr-domain: http://example.com:8902/api

即可避免路由转发过程中丢失 uri 的问题

参考:将权限放在缓存里

前面我们讲解了如何通过StpInterface接口注入权限数据,框架默认是不提供缓存能力的,如果你想减小数据库的访问压力,则需要将权限数据放到缓存中


参考如下:

java
/**
 * 自定义权限验证接口扩展 
 */
@Component  
public class StpInterfaceImpl implements StpInterface {
    
	// 返回一个账号所拥有的权限码集合 
    @Override
    public List<String> getPermissionList(Object loginId, String loginType) {
    	
    	// 1. 声明权限码集合
    	List<String> permissionList = new ArrayList<>();
    	
    	// 2. 遍历角色列表,查询拥有的权限码 
    	for (String roleId : getRoleList(loginId, loginType)) {
    		CpSession roleSession = CpSessionCustomUtil.getSessionById("role-" + roleId);
    		List<String> list = roleSession.get("Permission_List", () -> {
    			return ...;	 // 从数据库查询这个角色所拥有的权限列表  
    		});
    		permissionList.addAll(list);
    	}
    	
    	// 3. 返回权限码集合
    	return permissionList;
    }
	
    // 返回一个账号所拥有的角色标识集合 
    @Override
    public List<String> getRoleList(Object loginId, String loginType) {
    	CpSession session = StpUtil.getSessionByLoginId(loginId);
    	return session.get("Role_List", () -> {
    		return ...; // 从数据库查询这个账号id拥有的角色列表 
    	});
    }
	
}
疑问:为什么不直接缓存 [账号id->权限列表]的关系,而是 [账号id -> 角色id -> 权限列表]

答:[账号id->权限列表]的缓存方式虽然更加直接粗暴,却有一个严重的问题:

  • 通常我们系统的权限架构是RBAC模型:权限与用户没有直接的关系,而是:用户拥有指定的角色,角色再拥有指定的权限
  • 而这种'拥有关系'是动态的,是可以随时修改的,一旦我们修改了它们的对应关系,便要同步修改或清除对应的缓存数据

现在假设如下业务场景:我们系统中有十万个账号属于同一个角色,当我们变动这个角色的权限时,难道我们要同时清除这十万个账号的缓存信息吗? 这显然是一个不合理的操作,同一时间缓存大量清除容易引起Redis的缓存雪崩

而当我们采用 [账号id -> 角色id -> 权限列表] 的缓存模型时,则只需要清除或修改 [角色id -> 权限列表] 一条缓存即可

一言以蔽之:权限的缓存模型需要跟着权限模型走,角色缓存亦然

技术选型:SSO 与 OAuth2 对比


经常有小伙伴提问:项目需要搭建统一认证中心,是用 SSO 方便还是 OAuth2.0 方便呢?针对这个问题,我们列出两者的主要区别以供大家参考:

功能点SSO单点登录OAuth2.0
统一认证支持度高支持度高
统一注销支持度高支持度低
多个系统会话一致性强一致弱一致
第三方应用授权管理不支持支持度高
自有系统授权管理支持度高支持度低
Client级的权限校验不支持支持度高
集成简易度比较简单难度中等

注:以上仅为在 UAAC 中两种技术的差异度比较,不同框架的实现可能略有差异,但整体思想是一致的。

自定义 CpContext 指南

目前 UAAC 仅对 SpringBoot、SpringMVC、WebFlux、Solon 等部分 Web 框架制作了 Starter 集成包, 如果我们使用的都 Web 框架不在上述列表之中,则需要自定义 CpContext 接口的实现完成整合工作。


1、CpContext是什么,为什么要实现 CpContext 接口?

在鉴权中,必不可少的步骤就是从 HttpServletRequest 中读取 Token,然而并不是所有框架都具有 HttpServletRequest 对象,例如在 WebFlux 中,只有 ServerHttpRequest, 在一些其它Web框架中,可能连 Request 的概念都没有。

那么,UAAC 如何只用一套代码就对接到所有 Web 框架呢?

解决这个问题的关键就在于 CpContext 接口,此接口的作用是屏蔽掉不同 Web 框架之间的差异,提供统一的调用API:

CpContext只是一个接口,没有工作能力,这也就意味着 CpContext 接口的实现是必须的。 那么疑问来了,我们之前在 SpringBoot 中引用 UAAC 时为什么可以直接使用呢?

其实原理很简单,uaac-spring-boot-starter集成包中已经内置了CpContext的实现CpContextForSpring.java, 并且根据 Spring 的自动注入特性,在项目启动时注入到 UAAC 中,做到“开箱即用”。

那么如果我们使用不是 Spring 框架,是不是就必须得手动实现 CpContext 接口?答案是肯定的,脱离Spring 环境后,我们就不能再使用uaac-spring-boot-starter集成包了, 此时我们只能引入 uaac-core 核心包,然后手动实现 CpContext 接口。

不过不用怕,这个工作很简单,只要跟着下面的文档一步步来,你就可以将 UAAC 对接到任意Web框架中。

2、实现 Model 接口

我们先来观察一下 CpContext 接口的签名:

java
/**
 * UAAC 上下文处理器
 */
public interface CpContext {

	/**
	 * 获取当前请求的 [Request] 对象
	 */
	public CpRequest getRequest();

	/**
	 * 获取当前请求的 [Response] 对象
	 */
	public CpResponse getResponse();

	/**
	 * 获取当前请求的 [存储器] 对象 
	 */
	public CpStorage getStorage();

	/**
	 * 校验指定路由匹配符是否可以匹配成功指定路径 
	 */
	public boolean matchPath(String pattern, String path);

}

你可能对 CpRequest 比较疑惑,这个对象是干什么用的?正如每个 Web 框架都有 Request 概念的抽象,UAAC 也封装了 RequestResponseStorage三者的抽象, 因此在实现 CpContext 之前,你必须先实现这三个 Model 接口。

先别着急动手,如果你的 Web 框架是基于 Servlet 规范开发的,那么 UAAC 已经为你封装好了三个 Model 接口的实现,你要做的就是引入 uaac-servlet包即可:

xml
<!-- UAAC 权限认证(ServletAPI 集成包) -->
<dependency>
    <groupId>com.cpit.csc</groupId>
    <artifactId>uaac-servlet</artifactId>
    <version>${uaac.top.version}</version>
</dependency>

如果你的 Web 框架不是基于 Servlet 规范,那么你就需要手动实现这三个 Model 接口,我们可以参考 uaac-servlet 是怎样实现的: CpRequestForServlet.java、 CpResponseForServlet.java、 CpStorageForServlet.java。

3、实现 CpContext 接口

接下来我们奔入主题,提供 CpContext 接口的实现,同样我们可以参考 Spring 集成包是怎样实现的:

java
/**
 * UAAC 上下文处理器 [ SpringMVC版本实现 ] 
 */
public class CpContextForSpring implements CpContext {

	/**
	 * 获取当前请求的Request对象
	 */
	@Override
	public CpRequest getRequest() {
		return new CpRequestForServlet(SpringMVCUtil.getRequest());
	}

	/**
	 * 获取当前请求的Response对象
	 */
	@Override
	public CpResponse getResponse() {
		return new CpResponseForServlet(SpringMVCUtil.getResponse());
	}

	/**
	 * 获取当前请求的 [存储器] 对象 
	 */
	@Override
	public CpStorage getStorage() {
		return new CpStorageForServlet(SpringMVCUtil.getRequest());
	}
	
	/**
	 * 校验指定路由匹配符是否可以匹配成功指定路径 
	 */
	@Override
	public boolean matchPath(String pattern, String path) {
		return CpPathMatcherHolder.getPathMatcher().match(pattern, path);
	}

}

详细参考: CpContextForSpring.java

4、将自定义实现注入到 UAAC 框架中

有了 CpContext 接口的实现,我们还需要将这个实现类注入到 UAAC 之中,伪代码参考如下:

java
/**
 * 程序启动类
 */
public class Application {

	public static void main(String[] args) {
		// 框架启动
		XxxApplication.run(xxx);
		
		// 将自定义的 CpContext 实现类注入到框架中 
		CpContext uaacContext = new CpContextForXxx();
		CpManager.setCpContext(uaacContext);
	}
	
}

如果你使用的框架带有自动注入特性,那就更简单了,参考 Spring 集成包的 Bean 注入流程: 注册Bean - SaBeanRegister.java、 注入Bean - SaBeanInject.java。

5、启动项目

启动项目,尝试打印一下 CpManager.getCpContext() 对象,如果输出的是你的自定义实现类,那就证明你已经自定义 CpContext 成功了, 快来体验一下 UAAC 的各种功能吧。

框架源码所有技术栈

包括但不限于以下:

  • Maven多模块项目
  • Servlet API、临时Cookie与永久Cookie、Request参数获取
  • SpringBoot2.0、Redis、Jackson、Hutool、jwt
  • SpringBoot自定义starter、Spring包扫码 + 依赖注入、AOP注解切面、yml配置映射、拦截器
  • Java8 接口与default实现、静态方法、枚举、定时器、异常类、泛型、反射、IO流、自定义注解、Lambda表达式、函数式编程
  • package-info注释、Serializable序列化接口、synchronized锁
  • java加密算法:MD5、SHA1、SHA256、AES、RSA
  • OAuth2.0、同域单点登录、集群与分布式、路由Ant匹配

集成统一认证

1. 集成说明

1.1 引入依赖

xml
<dependency>
    <groupId>com.cpit.csc</groupId>
    <artifactId>uaac-unified-certification</artifactId>
    <version>${uaac.version}</version>
</dependency>

1.2 配置文件

yaml
cpit:
  certification:
    # 系统id
    systemId: xxxxx
    # 统一认证接口地址
    address: http://xxxx:xx

2. 接口规范

2.1 二维码返回接口

接口描述
接口名称二维码返回接口
接口描述返回二维码内容
接口方式http+Json POST
接口地址/certification/code
请求参数

应答参数
元素名称约束类型描述取值说明
type1Stringsuccess:成功
fail:失败
code1Integer1:成功
0:失败
message1String描述信息
result
systemID1String系统ID
redirectUrl1String业务系统接收统一认证票据和二维码ID的地址
codeId1String当前二维码的id
businessType1String业务类型login
businessData1String业务数据暂无
toPage1String跳转的应用界面暂无
报文样例
json
{}
json
{
    "type": "success",
    "code": 1,
    "message": "操作成功",
    "result": {
        "businessData": "xxxxxxxx",
        "businessType": "contract",
        "redirectUrl": "https://xxxx.com/xxxx",
        "systemID": "10011",
        "toPage": "",
        "codeId": ""
    }
}

2.2 统一认证回调接口

接口描述
接口名称统一认证回调接口
接口描述二维码登录统一认证回调接口
接口方式http+Json POST
接口地址/certification/verifyCallback
请求参数
元素名称约束类型描述取值说明
resultCode1String结果编码0000通过
10001用户未查到
10002二维码过期
resultType1String结果类型0成功, 其他失败
resultDesc1String描述信息10002 二维码过期
artifact1String票据
codeId1String系统ID统一认证发送
应答参数
元素名称约束类型描述取值说明
code1String三方系统收到统一认证请求后返回0000

2.3 应用系统密码校验接口(暂未使用)

使用需知

需继承UnifiedCheckService,重写校验账号密码

java
@Service
public class UnifiedCheckServiceImpl implements UnifiedCheckService {

    @Autowired
    private IUserService iUserService;

    @Override
    public boolean accountCheck(String pracct, String pwd) {
        UserVo userVo = new UserVo();
        userVo.setUserName(pracct);
        userVo.setUserPasswd(PasswordUtil.encryptToMD5(pwd));
        SysUser sysUser = iUserService.findAvailableUser(userVo);
        return sysUser != null;
    }
}
接口描述
接口名称密码校验接口
接口描述应用系统密码校验接口
接口方式http Get
接口地址/certification/pracctPasswordVerify
请求参数
元素名称约束类型描述取值说明
encryptParams1String账号密码示例:
应答参数
元素名称约束类型描述取值说明
ResCode1String校验通过返回0000
ResDesc1String

3. 提供Api

java
//根据codeId获取用户信息,二维码登录时可用
UserInfo info = CpCrtUtil.getUserInfo(codeId)
//统一认证校验账号、密码
UserInfo info = CpCrtUtil.accountCheck(pracct, pwd);