1、OAuth2单点认证原理
基于OAuth2的认证方式包含四种,其中单点登录最常用的是授权码模式,其基本的认证过程如下:
- 用户访问业务应用,业务应用进行登录检查;
- 业务应用重定向到OAuth2认证服务器,调用获取授权码的认证接口;
- OAuth2认证服务负责判断登录状态,如果未登录,则跳转到统一认证登录页面,如果已经登录,则直接到步骤5;
- 用户输入用户名、密码进行授权确认;
- 授权成功后,OAuth2认证服务携带授权码,重定向到指定的回调地址;
- 跳转到业务应用后,业务应用收到授权码,然后携带授权码,换取OAuth2的授权token;
- OAuth2认证服务校验授权码成功,返回授权token;
- 业务应用携带授权token,调用OAuth2接口获取用户信息;
- OAuth2认证服务校验授权token成功,返回用户信息;
- 业务系统根据用户信息,创建本系统的登录信息,单点登录成功。
2、启动OAuth2服务
平台提供了一个基于开源项目“spring-security-oauth2-authorization-server”封装的OAuth2服务,工程名是“yuncheng-oauth-boot”。
2.1、配置数据库链接
- 因OAuth2集成涉及多处配置,平台出厂已经默认配好,建议初学者第一次本地调试时,保持默认端口9000、服务根路径为空不变。后续有多处默认配置OAuth2服务地址是“http://127.0.0.1:9000”。
- 数据库链接与平台保持一致,读同一个数据库,这样就保证了OAuth2的用户信息与平台维护的用户信息一致。
2.2、配置用户登录标识
大部分情况下,系统使用username,即登录名,作为用户的唯一标识,唯一标识在OAuth2认证服务、平台服务、业务系统之间传递,完成单点登录认证。
如果需要使用手机号或者邮箱作为唯一标识,可以修改后端yml里的配置“yuncheng.loginNameType”,值范围包括:username,即登录名;phone,即手机号;email,即邮箱,不配置时,默认为“username”。
配置修改后,在OAuth2的登录页面,就可以使用对应的唯一标识进行登录了。
注意:如果配置了用户登录标识,那么,平台的yml文件,以及业务系统的集成代码(后面有个示例介绍业务系统如何集成),也需要使用相同的用户登录标识,保持一致。
2.3、启动服务
配置好yml文件的参数,启动OAuth2服务即可。
3、云程平台集成OAuth2服务
3.1、配置前端参数
在“public/config/bootConfig.js”文件中配置“VUE_APP_SSO”为“oauth2”,意为开启OAuth2单点登录功能。
开启后,需同时配置“VUE_APP_OAUTH2_URL”、“VUE_APP_OAUTH2_CLIENT_ID”、“VUE_APP_OAUTH2_SCOPE”的值,如果是本地调试,平台出厂已经默认配好。
参数说明:
- VUE_APP_OAUTH2_URL:上一步启动的OAuth2的服务地址。
- VUE_APP_OAUTH2_CLIENT_ID:在OAuth2注册的客户端ID,平台出厂脚本中默认有一条“yuncheng-client”的客户端配置,客户端ID需要结合第4部分的《启用OAuth2客户端注册模块》理解。
- VUE_APP_OAUTH2_SCOPE:请求认证的权限范围,保持固定参数“openid”不变即可。
3.2、配置后端参数
在yml文件中配置“yuncheng.oauth2-client.default”的值,平台出厂已经默认配好,初学者第一次本地调试时,可以保持不变。
参数说明:
- provider-uri:上一步启动的OAuth2的服务地址。
- client-id:在OAuth2注册的客户端ID,平台出厂脚本中默认有一条“yuncheng-client”的客户端配置。
- client-secret:在OAuth2注册的客户端ID的密钥。
- redirect-uri:在OAuth2注册的客户端ID的重定向地址,也就是平台前端的访问地址,注意本地调试不要使用“localhost”,应使用“127.0.0.1”。
客户端ID、客户端ID密钥、客户端ID重定向地址,需要结合第4部分的《启用OAuth2客户端注册模块》理解。
3.3、配置用户登录标识
在启动OAuth2服务的时候,配置了用户登录标识,在云程平台的服务中,也需要配置与OAuth2一致的用户登录标识。
3.4、启动平台
配置好参数后,启动平台。
3.5、测试单点效果
访问平台,如果没有登录,系统会跳转到OAuth2的登录页面,账号、密码同平台一致,输入账号密码,登录成功后会跳转到平台首页。
4、启用OAuth2客户端注册模块
4.1、授权客户端注册菜单
平台提供了一个“客户端注册”模块,用于管理OAuth2的客户端数据,该模块的菜单默认是未授权的。
使用管理员账号进入平台控制台->角色授权->后台角色->后台管理员,勾中“配置管理”下的“客户端注册”菜单及其子菜单,点击保存,完成授权。
刷新页面,重新加载授权信息,就可以看到“配置管理”菜单下的“客户端注册”菜单了。
4.2、说明客户端注册参数
模块下有一条默认数据“yuncheng-client”,就是平台的默认配置中使用的OAuth2客户端ID,不要删除这条数据。
如果要变更默认配置,可以通过“修改密钥”按钮,修改客户端ID的密钥,通过“编辑”按钮,修改客户端ID的重定向地址、授权范围等信息,也可以点击“新增”,新注册一个客户端ID。
修改或新增后,再对应修改第二部分平台的默认配置,包括前端配置和后端配置,然后重启平台服务。
需要注意的是,密钥分为“加密”和“明文”两种方式,加密方式不可逆,如果配置加密方式,需要自己提前记录下密钥原文,防止丢失。
密钥前面的字符串“{noop}”等,是加密方式,不需要关心,配置到配置文件中的应该是密钥原文。
新注册一个客户端ID,为下一步的业务系统集成OAuth2提供客户端ID。
参数说明:
- 客户端ID:client-id,客户端的唯一标识。
- 密钥类型:分为加密和明文两种方式。
- 密钥:密钥原文,如果密钥类型选择“加密”,需要提前记录下密钥原文,防止丢失。
- 客户端名称:客户端中文名称。
- 认证方式:单点登录保持默认即可。
- 重定向地址:认证请求后的重定向地址,也就是业务服务的访问地址,与平台或业务服务发送认证请求时携带的redirect_uri参数一致。如果平台或业务服务地址变动了,这里也需要同步修改,业务服务的请求参数也需要同步修改,最终需要保持一致。
- 授权范围:单点登录保持默认即可。
5、业务系统集成OAuth2
5.1、同步用户
必须完成与平台的用户同步,与OAuth2的用户保持一致,才能使用OAuth2的单点登录功能。
用户的唯一标识是可配置的,可以是username(登录名)、phone(手机号)、email(邮箱)中的一个,这三个关键字段各业务系统应该保持一致。
5.1.1、从平台同步
根据平台提供的接口,业务系统主动从平台拉取数据,完成用户同步。
1、获取所有平台用户
对应接口:List<UserActorImpl> getAllUserList()
接口地址:http://127.0.0.1:30001/api/system/sysOrgConver/getAllUserList (注:接口地址路径以实际为准)
请求类型:GET
参数:无
返回值:HttpResult<List<UserActorImpl>>用户对象集合
返回值示例:
{
"code": 200,
"message": "操作成功",
"success": true,
"timestamp": 1630752126366,
"result": [{
"id": "1373536011387523073",
"loginName": "admin",
"name": "管理员",
"createTime": 1577808000000,
"deptId": "1373536559281065985",
"deptCode": " A01A06",
"orgCode": "A01A06",
"deptName": "研发部",
"email": "aaa@163.com",
"phone": "13801066662",
"weixin": "xxxxxx"
}]
}
2、通过用户id获取用户信息
对应接口:UserActorImpl getUserById(String userId)
接口地址:http://127.0.0.1:30001/api/system/sysOrgConver/getUserById?userId=xxxxx (注:接口地址路径以实际为准)
请求类型:GET
参数:userId
返回值:HttpResult<UserActorImpl>(用户对象)
返回值示例:
{
"code": 200,
"message": "操作成功",
"success": true,
"timestamp": 1630752126366,
"result": {
"id": "1373536011387523073",
"loginName": "admin",
"name": "管理员",
"createTime": 1577808000000,
"deptId": "1373536559281065985",
"deptCode": " A01A06",
"orgCode": "A01A06",
"deptName": "研发部",
"email": "aaa@163.com",
"phone": "13801066662",
"weixin": "xxxxxx"
}
}
5.1.2、从钉钉同步
平台完成了钉钉与平台的用户同步功能,可以参考文档:https://yunchengxc.yuque.com/staff-kxgs7i/public/ir11upm4igg0egr1#OLjQt,结合钉钉官方API文档,自行开发钉钉用户同步功能。
5.2、改造代码完成集成
为了便于理解,我们选取一个基于SpringMVC开发的开源工程作为示例,讲解OAuth2的集成过程和原理,如果您的系统不是SpringMVC的技术栈,您也可以参考这个思路,完成自己代码的OAuth2集成。
5.2.1、引入Jar包
集成OAuth2的代码改造中,引入了4个工具Jar包,分别是JSON解析工具fastjson、远程调用工具httpclient和httpcore、jwt解析工具java-jwt。如果您的项目中已经有类似功能的工具包,也可以使用。
5.2.2、改造登录拦截逻辑
下图是SpringMVC的拦截器,主要做了两部分改造。
- 红框1:定义排除拦截的请求,原逻辑是排除登录请求,改为排除“/oauth2.action”,“/oauth2.action”是业务系统提供给OAuth2的回调地址,也就是客户端配置的重定向地址。下一步有这个服务的具体实现示例。
- 红框2:如果拦截器判断用户未登录,原逻辑是跳转到本系统的登录页面,改为跳转到OAuth2认证页面。具体逻辑见下图,其中使用的参数的含义后面会说明。这里需要增加一个session属性记录要访问的地址,当认证成功后,系统跳转时,可以从session中拿到该地址,进行跳转,保证业务请求是连贯的。
请求OAuth2需要携带一些参数,可以定义一个参数常量类,也可以使用配置文件配置的方式,本例使用了常量类,定义的参数含义说明如下:
- clientId: 客户端ID。
- clientSecret:客户端ID的密钥。
- redirectUri:客户端定义的重定向地址。
- providerUri:OAuth2的服务地址
- tokenUri:换取token请求路径,固定成“/oauth2/token”。
- authorizeUri:权限认证的请求路径,固定成“/oauth2/authorize”。
- logoutUri:退出请求路径,固定成“/logout”。
- loginNameType:单点登录身份标识类型,配置哪个属性标识唯一用户,默认为username,值范围包括: username(登录名)、phone(手机号)、email(邮箱)。
这些参数的值与第3部分注册的客户端对应。
下面是截图涉及的源码,供参考。
/**
* 登录拦截器
*/
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 获取请求的URL
String url = request.getRequestURI();
// 注释掉原来的不拦截逻辑
// if (url.indexOf("/login.action") >= 0) {
// return true;
// }
// 不拦截oauth2的回调请求
if (url.indexOf("/oauth2.action") >= 0) {
return true;
}
// 获取Session
HttpSession session = request.getSession();
User user = (User) session.getAttribute("USER_SESSION");
// 判断Session中是否有用户数据,如果有,则返回true,继续向下执行
if (user != null) {
return true;
}
// 注释掉原来的登录跳转逻辑,改为跳转到oauth2单点登录页面
// request.setAttribute("msg", "您还没有登录,请先登录!");
// request.getRequestDispatcher("/WEB-INF/jsp/login.jsp").forward(request, response);
// 记录当前请求地址,oauth2认证通过后调用回调地址时,会使用这个记录,跳转到用户想要访问的页面
String contextPath = request.getContextPath();
String nextUrl = url;
if (contextPath != null && !contextPath.equals("")) {
nextUrl = nextUrl.substring(request.getContextPath().length());
}
session.setAttribute("nextUrl", nextUrl);
// 跳转到oauth2的登录页面
String oauth2Url = Oauth2Constant.providerUri + Oauth2Constant.authorizeUri;
oauth2Url += "?response_type=code";
oauth2Url += "&client_id=" + Oauth2Constant.clientId;
oauth2Url += "&scope=openid";
oauth2Url += "&redirect_uri=" + Oauth2Constant.redirectUri;
response.sendRedirect(oauth2Url);
return false;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
}
}
/**
* oauth2参数定义
*/
public class Oauth2Constant {
// 客户端ID
public static final String clientId = "crm-client";
// 密钥
public static final String clientSecret = "crm-client";
// 重定向地址
public static final String redirectUri = "http://127.0.0.1:8090/crm/oauth2.action";
// oauth2服务地址
public static final String providerUri = "http://127.0.0.1:9000";
// 换取token请求路径
public static final String tokenUri = "/oauth2/token";
// 权限认证的请求路径
public static final String authorizeUri = "/oauth2/authorize";
// 退出请求路径
public static final String logoutUri = "/logout";
// 单点登录身份标识类型,配置哪个属性标识唯一用户,默认为username
// 值范围: username:登录名;phone:手机号;email:邮箱
public static final String loginNameType = "username";
}
5.2.3、集成OAuth2认证服务
OAuth2认证通过后,会携带授权码重定向到业务系统指定服务地址,也就是上一步请求地址的“redirectUri”参数指定的地址,业务系统需要实现该服务,接收授权码,然后携带授权码,换取OAuth2的授权token,完成本系统的登录。对应第1部分原理图的步骤6-步骤10。
- 红框1:参数校验,如果code为空,则跳转到登录页。一般退出操作导致的重定向是没有code参数的。
- 红框2:使用code获取OAuth2的userToken,使用jwt解析工具类,从token中解析出用户唯一标识,这里使用了自己封装的一个工具类“Oauth2RestClient”,后面有源码供参考。
- 红框3:根据配置的用户唯一标识的类型,调用本系统对应的的获取用户接口,获得用户对象。
- 红框4:如果用户存在,则创建本系统的session,完成登录,如果用户不存在,则跳转OAuth2的退出页面,参数含义可以参考上一部分的参数含义说明。登录成功后的跳转地址,原逻辑是跳转到首页,这里需要从session记录里取到认证前要访问的地址,然后跳转到该地址。
下图是自己封装的一个工具类“Oauth2RestClient”。
下面是截图涉及的源码,供参考。
@Controller
public class Oauth2Controller {
@Resource
private UserService userService;
/**
* oauth2重定向地址,即回调地址
*/
@RequestMapping(value = "/oauth2.action")
public String oauth2(@RequestParam(name = "code", required = false) String code, HttpSession session) throws IOException {
if (code == null || "".equals(code)) {
// 如果参数不全,返回到登录页面
// 推出之后,会跳转回该地址,此时没有code,需要校验
return "redirect:login.action";
}
// 初始化工具类
Oauth2RestClient oauth2RestClient = new Oauth2RestClient();
// 换取accessToken
UserToken userToken = oauth2RestClient.validCode(code);
// 获取用户名
String username = oauth2RestClient.getUserName(userToken);
// 查询用户
User user = null;
if ("phone".equals(Oauth2Constant.loginNameType)) {
user = userService.findUserByPhone(username);
} else if ("email".equals(Oauth2Constant.loginNameType)) {
user = userService.findUserByEmail(username);
} else {
user = userService.findUserByUserCode(username);
}
if (user != null) {
// 将用户对象添加到Session
session.setAttribute("USER_SESSION", user);
// 注释掉原来的跳转到主页面的逻辑
// return "redirect:customer/list.action";
// 改为从记录中拿到认证前想要访问的请求地址,进行跳转
String url = (String) session.getAttribute("nextUrl");
return "redirect:" + url;
}
// 如果登录失败,重定向到oauth2的退出页面
String oauth2Url = Oauth2Constant.providerUri + Oauth2Constant.logoutUri;
oauth2Url += "?redirect_uri=" + Oauth2Constant.redirectUri;
return "redirect:" + oauth2Url;
}
}
/**
* oauth2客户端工具类
*/
public class Oauth2RestClient {
public UserToken validCode(String code) throws IOException {
String url = Oauth2Constant.providerUri + Oauth2Constant.tokenUri;
Map<String, String> map = new HashMap<>();
map.put("grant_type", "authorization_code");
map.put("client_id", Oauth2Constant.clientId);
map.put("client_secret", Oauth2Constant.clientSecret);
map.put("redirect_uri", Oauth2Constant.redirectUri);
map.put("code", code);
URI uri = this.createURI(url, map);
return this.doPost(uri).toJavaObject(UserToken.class);
}
public String getUserName(UserToken userToken) {
DecodedJWT jwt = JWT.decode(userToken.getAccessToken());
return jwt.getSubject();
}
private URI createURI(String url, Map<String, String> map) {
int loop = 1;
for (Map.Entry<String, String> entry : map.entrySet()) {
if (StringUtils.isNotEmpty(entry.getValue())) {
url += loop == 1 ? "?" : "&";
url += entry.getKey() + "=" + entry.getValue();
loop++;
}
}
return URI.create(url);
}
private JSONObject doPost(URI uri) throws IOException {
CloseableHttpClient httpClient = HttpClients.createDefault();
HttpPost httpPost = new HttpPost(uri);
CloseableHttpResponse response = httpClient.execute(httpPost);
String resultString = EntityUtils.toString(response.getEntity(), "utf-8");
return JSON.parseObject(resultString);
}
}
/**
* oauth2对象
*/
public class UserToken {
private String accessToken;
private String refreshToken;
private String tokenType;
private String scope;
private String idToken;
public String getAccessToken() {
return accessToken;
}
public void setAccessToken(String accessToken) {
this.accessToken = accessToken;
}
public String getRefreshToken() {
return refreshToken;
}
public void setRefreshToken(String refreshToken) {
this.refreshToken = refreshToken;
}
public String getTokenType() {
return tokenType;
}
public void setTokenType(String tokenType) {
this.tokenType = tokenType;
}
public String getScope() {
return scope;
}
public void setScope(String scope) {
this.scope = scope;
}
public String getIdToken() {
return idToken;
}
public void setIdToken(String idToken) {
this.idToken = idToken;
}
}
5.2.4、改造退出功能
原退出逻辑是销毁session,跳转到本系统登录页面,改为销毁session,跳转到OAuth2的退出页面,参数含义可以参考上一部分的参数含义说明。
下面是截图涉及的源码,供参考。
/**
* 退出登录
*/
@RequestMapping(value = "/logout.action")
public String logout(HttpSession session) {
// 清除Session
session.invalidate();
// 重定向到oauth2的退出页面
String oauth2Url = Oauth2Constant.providerUri + Oauth2Constant.logoutUri;
oauth2Url += "?redirect_uri=" + Oauth2Constant.redirectUri;
return "redirect:" + oauth2Url;
// 重定向到登录页面的跳转方法
// return "redirect:login.action";
}
5.3、测试单点效果
如下图,可以在平台的菜单中,配置一个集成好的业务系统的访问地址。完成菜单授权,刷新页面。
在平台与OAuth2集成、业务系统与OAuth2集成都正确的情况下,通过OAuth2认证页面登录平台后,在菜单中访问第三方业务系统,无需登录即可直接打开,即完成了单点登录集成。