随着公司系统的增加,每次新建一个项目是否还在做登录功能呢,还在做重复的工作?统一登录SSO你值得拥有。本文主要讲解,基于令牌token方式实现,SpringBoot工程下的SSO单点登录整合代码实例Demo,文末附源码地址。
1.环境准备
- SSO认证中心服务( www.mysso.com)
- 客户端1(www.myclient1.com)
- 客户端2(www.myclient2.com)
由于是Demo实例,这里若有要访问-需要修改一下本机host,添加如下映射
127.0.0.1 www.mysso.com 127.0.0.1 www.myclient1.com 127.0.0.1 www.myclient2.com
2.搭建SSO认证中心服务
问题来了,搭建一个SSO统一登录需要哪些功能?
- 统一登录页+接口
- 校验令牌有效性接口(调用来源:子系统)
- 校验登录状态接口(调用来源:子系统)
- 统一退出(调用来源:子系统或退出认证中心服务)
这里就不贴Maven依赖了,主要讲解功能,详见文末附源码。
2.1统一登录页
创建一个统一登录页
<html> <head> <meta charset="UTF-8"> <title>程序员小强-SSODemo
title>
head> <style>body.center {
text-align: center; }
style> <body class="center"> <div> <h1>SSO用户统一登录
h1>
div> <div> <form name="loginForm" action="/sso/login" method="POST" accept-charset="UTF-8"> <div><input placeholder="用户名" value="admin" name="username" type="text"/>
div> <div><input placeholder="密码" value="" name="password" type="password"/>
div> <div style="color: red"><span th:text="${msg}">
span>
div> <div><input name="redirectUrl" type="hidden" th:value="${redirectUrl}"/>
div> <input type="submit" value="登录"/> <input type="reset" value="重置"/>
form>
div>
body>
html>
2.2统一登录接口
这里为了简化示例,未引入redis等缓存数据库,使用的 session存储登录信息
/ * 认证中心SSO统一登录方法 */ @RequestMapping("/login") public String login(LoginParam loginParam, RedirectAttributes redirectAttributes, HttpSession session, Model model) {
//Demo 项目此处模拟数据库账密校验 if (!"admin".equals(loginParam.getUsername()) || !"".equals(loginParam.getPassword())) {
model.addAttribute("msg", "账户或密码错误,请重新登录!"); model.addAttribute("redirectUrl", loginParam.getRedirectUrl()); return "login"; } //登录成功 //创建令牌 String ssoToken = UUID.randomUUID().toString(); //把令牌放到全局会话中 session.setAttribute("ssoToken", ssoToken); //设置session失效时间-单位秒 session.setMaxInactiveInterval(3600); //将有效的令牌-放到map容器中(存在该容器中的token都是合法的,正式环境建议redis或存库) SSOConstantPool.TOKEN_POOL.add(ssoToken); //未携带重定向跳转地址-默认跳转到认证中心首页 if (StringUtils.isEmpty(loginParam.getRedirectUrl())) {
return "index"; } // 携带令牌到客户端 redirectAttributes.addAttribute("ssoToken", ssoToken); log.info("[ SSO登录 ] login success ssoToken:{} , sessionId:{}", ssoToken, session.getId()); // 跳转到客户端 return "redirect:" + loginParam.getRedirectUrl(); }
2.3校验令牌接口
- 校验token是否有效
- 若有效-则记录了注册上来的子系统信息,用于统一注销时候使用
/ * 校验令牌是否合法 * * @param ssoToken 令牌 * @param loginOutUrl 退出登录访问地址 * @param jsessionid * @return 令牌是否有效 */ @ResponseBody @RequestMapping("/checkToken") public String verify(String ssoToken, String loginOutUrl, String jsessionid) {
// 判断token是否存在map容器中,如果存在则代表合法 boolean isVerify = SSOConstantPool.TOKEN_POOL.contains(ssoToken); if (!isVerify) {
log.info("[ SSO-令牌校验 ] checkToken 令牌已失效 ssoToken:{}", ssoToken); return "false"; } //把客户端的登出地址记录起来,后面注销的时候需要根据使用(生产环境建议存redis或库) List<ClientRegisterModel> clientInfoList = SSOConstantPool.CLIENT_REGISTER_POOL.computeIfAbsent(ssoToken, k -> new ArrayList<>()); ClientRegisterModel vo = new ClientRegisterModel(); vo.setLoginOutUrl(loginOutUrl); vo.setJsessionid(jsessionid); clientInfoList.add(vo); log.info("[ SSO-令牌校验 ] checkToken success ssoToken:{} , clientInfoList:{}", ssoToken, clientInfoList); return "true"; }
2.4校验登录状态接口
当令牌校验返回失败时,子系统需要调用此接口
/ * 校验是否已经登录认证中心(是否有全局会话) * 1.若存在则携带令牌ssoToken跳转至目标页面 * 2.若不存在则跳转到登录页面 */ @RequestMapping("/checkLogin") public String checkLogin(String redirectUrl, RedirectAttributes redirectAttributes, Model model, HttpServletRequest request) {
//从认证中心-session中判断是否已经登录过(判断是否有全局会话) Object ssoToken = request.getSession().getAttribute("ssoToken"); // ssoToken为空 - 没有全局回话 if (StringUtils.isEmpty(ssoToken)) {
log.info("[ SSO-登录校验 ] checkLogin fail 没有全局回话 ssoToken:{}", ssoToken); //登录成功需要跳转的地址继续传递 model.addAttribute("redirectUrl", redirectUrl); //跳转到统一登录页面 return "login"; } log.info("[ SSO-登录校验 ] checkLogin success 有全局回话 ssoToken:{}", ssoToken); //重定向参数拼接(将会在url中拼接) redirectAttributes.addAttribute("ssoToken", ssoToken); //重定向到目标系统 return "redirect:" + redirectUrl; }
2.5统一退出接口
/ * 统一注销 * 1.注销全局会话 * 2.通过监听全局会话session时效性,向已经注册的所有子系统发起注销请求 */ @RequestMapping("/logOut") public String logOut(HttpServletRequest request) {
HttpSession session = request.getSession(); log.info("[ SSO-统一退出 ] ....start.... sessionId:{}", session.getId()); //注销全局会话, SSOSessionListener 监听器会处理后续操作 request.getSession().invalidate(); log.info("[ SSO-统一退出 ] ....end.... sessionId:{}", session.getId()); return "logout"; }
退出监听,当统一认证中心session销毁时,同时注销子系统
/ * session监听器 * * @author 程序员小强 */ @Slf4j @WebListener public class SSOSessionListener implements HttpSessionListener {
/ * 销毁事件监听 * * 1.session超时的时候会调用 * 2.手动调用session.invalidate()方法时会调用. * * @param se */
@Override public void sessionDestroyed(HttpSessionEvent se) {
HttpSession session = se.getSession(); String token = (String) session.getAttribute("ssoToken"); log.debug("[ SSOSessionListener ] ...start..... sessionId:{},token:{}", session.getId(), token); //注销全局会话,SSOSessionListener监听类删除对应的信息 session.invalidate(); if (StringUtils.isEmpty(token)) {
log.debug("[ SSOSessionListener ] token is null sessionId:{}", session.getId()); return; } //清除存储的有效token数据 SSOConstantPool.TOKEN_POOL.remove(token); //清除并返回已经注册的系统信息 List<ClientRegisterModel> clientRegisterList = SSOConstantPool.CLIENT_REGISTER_POOL.remove(token); if (CollectionUtils.isEmpty(clientRegisterList)) {
return; } for (ClientRegisterModel client : clientRegisterList) {
if (null == client) {
continue; } //取出注册的子系统,依次调用子系统的登出方法(通过会话ID退出子系统的局部会话) sendHttpRequest(client.getLoginOutUrl(), client.getJsessionid()); log.info("[ SSOSessionListener ] 注销系统 url:{},Jsessionid:{}", client.getLoginOutUrl(), client.getJsessionid()); } log.debug("[ SSOSessionListener ] ...end..... sessionId:{},token:{}", session.getId(), token); } / * 发送退出登录请求 * 模拟浏览器访问形式 * * @param reqUrl 发送请求的地址 * @param jesssionId 会话Id */ private static void sendHttpRequest(String reqUrl, String jesssionId) {
try {
//建立URL连接对象 URL url = new URL(reqUrl); //创建连接 HttpURLConnection conn = (HttpURLConnection) url.openConnection(); //设置请求的方式(需要是大写的) conn.setRequestMethod("POST"); //设置需要响应结果 conn.setDoOutput(true); //通过设置JSESSIONID模拟浏览器端操作 conn.addRequestProperty("Cookie", "JSESSIONID=" + jesssionId); //发送请求到服务器 conn.connect(); conn.getInputStream(); conn.disconnect(); } catch (Exception e) {
log.error("[ sendHttpRequest ] exception >> reqUrl:{}", reqUrl, e); } } }
3.搭建客户端服务
问题来了,搭建一个客户端需要哪些功能?
- 拦截请求
- 请求认证中心校验令牌有效性
- 有效则创建局部会话
- 无效则继续请求认证中心登录
- 注销系统-请求认证中心统一注销
3.1核心请求拦截器实现
@Configuration public class WebConfig extends WebMvcConfigurationSupport {
/ * 创建拦截器 */ @Bean WebInterceptor webInterceptor() {
return new WebInterceptor(); } / * 添加拦截器-进行拦截 * addPathPatterns 添加拦截 * excludePathPatterns 排除拦截 / @Override public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(this.webInterceptor()) .addPathPatterns("/") .excludePathPatterns("/logOut"); super.addInterceptors(registry); } / * 返回值-编码 UTF-8 */ @Bean public HttpMessageConverter<String> responseBodyConverter() {
return new StringHttpMessageConverter(StandardCharsets.UTF_8); } @Override public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
configurer.favorPathExtension(false); } / * 资源处理器-资源路径 映射 * * @param registry */ @Override public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/webjars/") .addResourceLocations("classpath:/META-INF/resources/webjars/"); } }
/ * 创建拦截器-拦截需要安全访问的请求 * 方法说明 * 1.preHandle():前置处理回调方法,返回true继续执行,返回false中断流程,不会继续调用其它拦截器 * 2.postHandle():后置处理回调方法,但在渲染视图之前 * 3.afterCompletion():全部后置处理之后,整个请求处理完毕后回调。 * * @author 程序员小强 */ @Slf4j public class WebInterceptor implements HandlerInterceptor {
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
log.info("[ WebInterceptor ] >> preHandle requestUrl:{} ", request.getRequestURI()); //判断是否有局部会话 HttpSession session = request.getSession(); Object isLogin = session.getAttribute("isLogin"); if (isLogin != null && (Boolean) isLogin) {
log.debug("[ WebInterceptor ] >> 已登录,有局部会话 requestUrl:{}", request.getRequestURI()); return true; } //获取令牌ssoToken String token = SSOClientHelper.getSsoToken(request); //无令牌 if (StringUtils.isEmpty(token)) {
//认证中心验证是否已经登录(是否存在全局会话) SSOClientHelper.checkLogin(request, response); return true; } //有令牌-则请求认证中心校验令牌是否有效 Boolean checkToken = SSOClientHelper.checkToken(token, session.getId()); //令牌无效 if (!checkToken) {
log.debug("[ WebInterceptor ] >> 令牌无效,将跳转认证中心进行认证 requestUrl:{}, token:{}", request.getRequestURI(), token); //认证中心验证是否已经登录(是否存在全局会话) SSOClientHelper.checkLogin(request, response); return true; } //token有效,创建局部会话设置登录状态,并放行 session.setAttribute("isLogin", true); //设置session失效时间-单位秒 session.setMaxInactiveInterval(1800); //设置本域cookie CookieUtil.setCookie(response, SSOClientHelper.SSOProperty.TOKEN_NAME, token, 1800); log.debug("[ WebInterceptor ] >> 令牌有效,创建局部会话成功 requestUrl:{}, token:{}", request.getRequestURI(), token); return true; } }
4.源码介绍
5.源码实例测试
由于是Demo实例,这里若有要访问-需要修改一下本机host,添加如下映射
127.0.0.1 www.mysso.com 127.0.0.1 www.myclient1.com 127.0.0.1 www.myclient2.com
5.1 客户端1登录
5.1.1在浏览器端访问 www.myclient1.com:8082
5.2 客户端2登录
由于客户端1已经登录,也就是说已经存在全局会话了,那么在访问客户端2的时候,其实无需登录的,只需要认证中心将最新的ssoToken携带过来就可以了。
5.3 统一退出登录
说明统一退出登录成功了
至此SSO统一登录实战讲解完毕。
需要完善的点还很多,比如服务端与客户端交互的时候可以通过加密或者加签方式防止数据被篡改。
发布者:全栈程序员-站长,转载请注明出处:https://javaforall.net/199606.html原文链接:https://javaforall.net
