概述
单点登录(Single Sign On),简称为 SSO,是比较流行的企业业务整合的解决方案之一。SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统 -百度百科
SSO设计的好处
- 用户角度 :多个子应用只需登录一次,访问其他子应用无需再次登录,非常方便。
- 系统管理员角度 : 管理员只需维护好一个统一的账号中心就可以了,方便。
- 新系统开发角度: 新系统开发时只需直接对接统一的账号中心即可,简化开发流程,省时
方案设计
方案 | 系统架构 | 简介 |
---|---|---|
方案一 | 前端同域+同一个redis服务 | 共享 Cookie 同步会话 |
方案二 | 前端跨域+同一个redis服务 | URL重定向传播会话 |
方案三 | 前端跨域 + 不同Redis服务 | session共享会话 |
PS:本篇暂时只演示方案一,方案二、三目前还在设计中,目前这款后端代码适配方案一、二,前端稍作改动即可。
SSO核心功能点
功能 | 简述 |
---|---|
登录 | 支持跨域 |
注销 | 支持跨域 |
架构设计
通信流程
流程说明如下表所示:
序号 | 内容 |
---|---|
1 | 打开浏览器访问系统首页->http://client1.sso.com:9001/index |
2 | 进行拦截校验是否携带了令牌,没有携带重定向到SSO登录界面->http://server.sso.com/sso/login?redirect=http://client1.sso.com:9001/index |
3 | 在登录页面输入账号密码执行登录->http://server.sso.com/sso/doLogin?redirect=http://client1.sso.com:9001/index&account=zmy&password=123 |
4 | 校验账号密码,确认信息无误,生成token并标记该用户已登录,返回登录成功响应->{path:http://client1.sso.com:9001/index,token:xxxx} |
5 | 客户端接受到登录成功响应,将token拼接到回调url进行跳转->跳转至系统首页:http://client1.sso.com:9001/index?token=xxxxx |
6 | 加载系统首页之前获取url携带的token信息,存储在cookies(domain=.sso.com)中,然后重定向至系统首页->http://client1.sso.com:9001/index |
7 | 换一个域名模拟另一个子系统,打开浏览器访问系统首页->http://client2.sso.com:9001/index |
8 | 从cookie中domain=.sso.com,获取到令牌,重定向到认证中心->http://server.sso.com:8888/sso/auth?redirect=http://client2.sso.com:9001/index,进行认证 |
9 | 通过令牌检测到已登录,重定向系统首页->http://client2.sso.com:9001/index |
功能演示
首先在host文件中配置多个二级域名
127.0.0.1 server.sso.com
127.0.0.1 client1.sso.com
127.0.0.1 client2.sso.com
127.0.0.1 client3.sso.com
访问[http://client1.sso.com:9001],由于没有进行登录,会跳转到登录页面,登录页面地址[http://server.sso.com:9002/?redirect=http://client1.sso.com:9001#/login]
点击登录,进入到刚刚访问的[http://client1.sso.com:9001]页面,此时说明已经成功登录了
在client1已登录的情况下,访问[http://client2.sso.com:9001],由于client1已登录,client2、client3通过cookie共享会话,无需再次进行登录,可以点击注销进行统一注销
功能实现
后端
代码目录如下
演示链路如下:
前端请求-->拦截器缓存请求域-->进入控制器-->执行SSO处理器-->返回结果(重定向|REST响应)
application.yml
配置登录页面地址http://server.sso.com:9002
# 端口
server:
port: 8880
spring:
messages:
basename: i18n/messages
# 授权登录地址
sso:
login:
url: http://server.sso.com:9002 # 登录页面地址
account: zhangmuye # 模拟账号
password: 123456 # 模拟密码
配置拦截器SSOContextInterceptor
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private SSOContextInterceptor ssoContextInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(ssoContextInterceptor)
.addPathPatterns("/**");
}
}
@Log4j2
@Component
public class SSOContextInterceptor implements HandlerInterceptor {
@Autowired
private SSOManager ssoManager;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 填充会话
ssoManager.setContextSession(request,response);
return HandlerInterceptor.super.preHandle(request, response, handler);
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
// 清理会话
ssoManager.removeContextSession();
HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
}
}
会话管理器SSOManager
用于存储用户会话以及请求信息
@Controller
public class SSOManager {
/**
* 存储每次请求会话信息
*/
private static final ThreadLocal<SSOContextSession> contextSession = new ThreadLocal<>();
/**
* 存储用户会话
* key -> 令牌信息token
* value -> 用户信息SSOUserSession
*/
private static final Map<String,SSOUserSession> userSession = new HashMap<>();
/**
* 填充当前会话
*
* @author wei.chen
* @date 2022/7/5 22:47
* @param request
* @param response
* @return void
*/
@SneakyThrows
public void setContextSession(HttpServletRequest request, HttpServletResponse response){
SSOContextSession ssoContextSession = new SSOContextSession();
ssoContextSession.setRequest(request);
ssoContextSession.setResponse(response);
String accessToken = Optional.ofNullable(request.getHeader(SSOConstants.Properties.AUTHORIZATION))
.orElse(request.getParameter(SSOConstants.Properties.ACCESS_TOKEN));
String refreshToken = request.getParameter(SSOConstants.Properties.REFRESH_TOKEN);
String account = request.getParameter(SSOConstants.Properties.ACCOUNT);
String password = request.getParameter(SSOConstants.Properties.PASSWORD);
String redirect = request.getParameter(SSOConstants.Properties.REDIRECT);
String pathPostfix = request.getRequestURI();
if ("POST".equals(request.getMethod())){
JSONObject requestJsonObject = GetRequestJsonUtil.getRequestJsonObject(request);
if (ObjectUtil.isNotEmpty(requestJsonObject)) {
// 提取POST请求参数(账号、密码)
BeanUtil.copyProperties(requestJsonObject, ssoContextSession);
account = ssoContextSession.getAccount();
password = ssoContextSession.getPassword();
}
// doLogin时 redirect可能为空,从请求头获取redirect
if (StrUtil.isBlank(redirect)){
String referer = request.getHeader(SSOConstants.Properties.REFERER);
if (StrUtil.isNotBlank(referer)){
redirect = URLUtil.url(referer).getQuery().substring(9);
}
}
}
ssoContextSession.setAccessToken(accessToken);
ssoContextSession.setRefreshToken(refreshToken);
ssoContextSession.setAccount(account);
ssoContextSession.setPassword(password);
ssoContextSession.setRedirect(redirect);
ssoContextSession.setPostfixPath(pathPostfix);
contextSession.set(ssoContextSession);
}
/**
* 填充用户会话
*
* @author wei.chen
* @date 2022/7/5 23:28
* @param key
* @param ssoUserSession
* @return void
*/
public void setSSOUserSession(String key, SSOUserSession ssoUserSession){
userSession.put(key, ssoUserSession);
}
/**
* 获取当前会话
*
* @author wei.chen
* @date 2022/7/7 9:56
* @return {@link SSOContextSession }
*/
public SSOContextSession getContextSession(){
return contextSession.get();
}
/**
* 获取用户信息
*
* @author wei.chen
* @date 2022/7/7 9:56
* @param key
* @return {@link SSOUserSession }
*/
public SSOUserSession getSSOUserSession(String key){
return userSession.get(key);
}
/**
* 清理会话
* @author wei.chen
* @date 2022/7/7 10:40
* @param key
* @return {@link }
*/
public void removeUserSession(String key){
userSession.remove(key);
}
/**
* 移除当前会话
*
* @author wei.chen
* @date 2022/7/5 22:46
* @param
* @return void
*/
public void removeContextSession(){
contextSession.remove();
}
}
SSOClientController
用于接收来自/sso/xx
的请求,通过执行ssoProcessor.ssoProcess()
方法进行统一处理
@RestController
public class SSOClientController {
@Autowired
private SSOProcessor ssoProcessor;
/**
* 处理SSO请求
*
* @author wei.chen
* @date 2022/7/6 8:38
* @return {@link Object }
*/
@RequestMapping(value = "/sso/*")
public Object ssoHandle(){
return ssoProcessor.ssoProcess();
}
}
SSO处理器SSOProcessor
,包括(认证、登录、注销)
@Log4j2
@Component
public class SSOProcessor {
@Autowired
private SSOManager manager;
@Autowired
private SSOToken ssoToken;
@Autowired
private SSOYamlConfig ssoYamlConfig;
/**
* 服务端SSO请求进行统一处理
*
* @param
* @return java.lang.Object
* @author wei.chen
* @date 2022/7/5 23:34
*/
public Object ssoProcess() {
log.info("[ ssoProcess ] ===>服务端SSO请求进行统一处理");
SSOContextSession contextSession = manager.getContextSession();
if (ObjectUtil.isEmpty(contextSession)) {
throw new SSORunException(SSOExceptionStatus.IS_NULL_SESSION);
}
/* ---------------------------路由分发------------------------------ */
if (contextSession.isSamePostfixPath(SSOConstants.SSOUrl.SSO_AUTH)) {
// 进行统一认证
return ssoAuth();
}
if (contextSession.isSamePostfixPath(SSOConstants.SSOUrl.SSO_LOGIN)) {
// 重定向至登录页面
return ssoLogin();
}
if (contextSession.isSamePostfixPath(SSOConstants.SSOUrl.SSO_DO_LOGIN)) {
// 执行登录:rest请求->ajax请求不支持重定向
return ssoDoLogin();
}
if (contextSession.isSamePostfixPath(SSOConstants.SSOUrl.SSO_DO_LOGOUT)) {
// 执行注销
return ssoDoLogout();
}
log.error("[ ssoProcess ] ===>服务端SSO请求进行统一处理:{}",SSOExceptionStatus.INVALID_ACCESS.getMessage());
return Result.failure(SSOExceptionStatus.INVALID_ACCESS.getCode(), SSOExceptionStatus.INVALID_ACCESS.getMessage());
}
/**
* 统一认证
*
* @param
* @return java.lang.Object
* @author wei.chen
* @date 2022/7/5 23:17
*/
@SneakyThrows
private Object ssoAuth() {
log.info("[ ssoAuth ] ===>进入认证中心");
SSOContextSession contextSession = manager.getContextSession();
HttpServletResponse response = contextSession.getResponse();
// 是否合法的重定向地址
if (!Validator.isUrl(contextSession.getRedirect())) {
throw new SSORunException(SSOExceptionStatus.ILLEGALITY_REDIRECT);
}
// 校验令牌
if (!ssoToken.verifyToken(contextSession.getAccessToken())) {
// 认证未通过进行登录
return ssoLogin();
}
// 注册客户端地址
SSOUserSession ssoUserSession = manager.getSSOUserSession(contextSession.getAccessToken());
Set<String> clientUrls = Optional.ofNullable(ssoUserSession.getClientUrls()).orElse(new HashSet<String>());
String host = URLUtil.url(contextSession.getRedirect()).getHost();
if (!clientUrls.contains(host)){
clientUrls.add(host);
ssoUserSession.setClientUrls(clientUrls);
manager.setSSOUserSession(contextSession.getAccessToken(), ssoUserSession);
log.info("[ ssoAuth ] ===>成功注册HOST[{}]", host);
}
// 重定向至客户端
response.sendRedirect(contextSession.getRedirect());
return null;
}
/**
* 重定向至登录页面
*
* @param
* @return java.lang.Object
* @author wei.chen
* @date 2022/7/5 23:20
*/
@SneakyThrows
private Object ssoLogin() {
log.info("[ ssoLogin ] ===>重定向至登录页面");
SSOContextSession contextSession = manager.getContextSession();
HttpServletResponse response = manager.getContextSession().getResponse();
// 重定向至认证页面
response.sendRedirect(StrUtil.format(
"{}?{}={}",
ssoYamlConfig.getLoginUrl(),
SSOConstants.Properties.REDIRECT,
contextSession.getRedirect()));
return null;
}
/**
* 登录:rest请求
*
* @param
* @return java.lang.Object
* @author wei.chen
* @date 2022/7/5 23:23
*/
@SneakyThrows
private Object ssoDoLogin() {
log.info("[ ssoDoLogin ] ===>执行登录");
SSOContextSession contextSession = manager.getContextSession();
// 进行认证
if (!ssoYamlConfig.getAccount().equals(contextSession.getAccount())
|| !ssoYamlConfig.getPassword().equals(contextSession.getPassword())) {
// 认证失败
log.error("[ ssoDoLogin ] ===>执行登录:{}",SSOExceptionStatus.ACCOUNT_OR_PASSWORD_ERROR.getMessage());
throw new SSORunException(SSOExceptionStatus.ACCOUNT_OR_PASSWORD_ERROR);
}
// TODO 使用三方缓存Redis代替本机缓存
SSOUserSession ssoUserSession = new SSOUserSession();
// 模拟生成令牌,存储到缓存中
String accessToken = UUID.fastUUID().toString();
String refreshToken = UUID.fromString(accessToken).toString();
String host = URLUtil.url(contextSession.getRedirect()).getHost();
Set<String> clients = new HashSet<>();
clients.add(host);
ssoUserSession.setAccessToken(accessToken);
ssoUserSession.setRefreshToken(refreshToken);
ssoUserSession.setAccount(contextSession.getAccount());
ssoUserSession.setPassword(contextSession.getPassword());
ssoUserSession.setClientUrls(clients);
manager.setSSOUserSession(accessToken, ssoUserSession);
log.info("[ ssoDoLogin ] ===>成功注册HOST[{}]", host);
log.info("[ ssoDoLogin ] ===>登录成功,下发令牌");
// 下发令牌
return Result.success(new Dict()
.set(SSOConstants.Properties.PATH_POSTFIX, contextSession.getRedirect())
.set(SSOConstants.Properties.ACCESS_TOKEN, ssoUserSession.getAccessToken())
.set(SSOConstants.Properties.REFRESH_TOKEN, ssoUserSession.getRefreshToken())
);
}
/**
* 注销
*
* @return {@link Object }
* @author wei.chen
* @date 2022/7/7 10:39
*/
public Object ssoDoLogout() {
log.info("[ ssoDoLogout ] ===>执行注销");
SSOContextSession contextSession = manager.getContextSession();
// 拦截非法注销
if (!ssoToken.verifyToken(contextSession.getAccessToken())) {
log.error("[ ssoDoLogout ] ===>执行注销:{}",SSOExceptionStatus.ILLEGALITY_TOKEN.getMessage());
throw new SSORunException(SSOExceptionStatus.ILLEGALITY_TOKEN);
}
manager.removeUserSession(contextSession.getAccessToken());
log.info("[ ssoDoLogout ] ===>已成功注销");
return Result.success();
}
}
前端
sso-client
核心代码,未获取到令牌进入登录页面,否则进行认证令牌是否合法,合法重定向到当前访问界面
/**
* 挂载路由导航守卫
* @param {*} to 将要访问的路径
* @param {*} from 从哪个路径跳转而来
* @param {*} next 是一个函数,表示放行,next()放行,next('/login')表强制跳转
*/
router.beforeEach((to, from, next) => {
let token = VueCookie.get('token');
if(!token){
let redirectUrl = window.location.origin;
window.location.href="http://server.sso.com:8880/sso/login?redirect="+redirectUrl;
}else{
window.location.href="http://server.sso.com:8880/sso/auth?redirect="+redirectUrl;
}
})
sso-auth
核心代码,进行登录,登录成功拿到响应数据redirect地址与token令牌,将token令牌存储在Cookies domain=.sso.com中,达到同域共享令牌,紧接着重定向至要访问的页面
<template>
... 登录布局
</template>
<script>
export default {
data() {
return {
,
};
},
methods: {
//提交表单
login() {
this.loading = true;
console.log(window.location.href);
this.$http({
url: this.$http.adornUrl("sso/doLogin"),
method: "post",
data: this.$http.adornData(this.loginForm, false),
})
.then(({ data }) => {
data = data.data;
this.loading = false;
console.log("[ token ]=", data.access_token);
// 设置30分钟过期
var millisecond = new Date().getTime();
var expiresTime = new Date(millisecond + 60 * 30000);
this.$cookies.set("token", data.access_token, {
expires: expiresTime,domain: '.sso.com'
});
debugger
this.$message({
message: "登录成功",
type: "success",
duration: 1000,
onClose: function () {
window.location.href = data.path;
},
});
})
.catch((e) => {
this.$message({
message: e.data.message,
type: "error",
duration: 1000,
});
this.loading = false;
});
}
},
};
</script>