应用系统基于OAuth2实现单点登录的解决方案_app单点登录实现
阿里云国内75折 回扣 微信号:monov8 |
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6 |
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。
参数说明
- 客户端IDclient-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客户端定义的重定向地址。
- providerUriOAuth2的服务地址
- 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认证页面登录平台后在菜单中访问第三方业务系统无需登录即可直接打开即完成了单点登录集成。