SSO单点登录/登出系统实现

原创
2017/09/08 16:17
阅读数 8.8K

先把源码贴出来,再慢慢讲解思路和原理以及实现方式

-->源代码1.0

整合了Mybatis +redis/redis集群二级缓存+cookie加密机制+token

-->源代码2.1  密码:5npa

git:https://gitee.com/iyinghui/sso-yh

1 什么是单点登录?

这边文章说的很清楚:http://www.imooc.com/article/3555

2 功能分析

单点登录这里我用三个工程来 实现和演示

1 ssoServer 是认证中心,所有用户的登录操作都在这里完成

   基于maven ,使用ssm搭建

2 sso_app1 系统的分应用服务器1

   基于maven,因为只是用来演示,所以使用的是原生servlet

3 sso_app2 系统的分应用服务器2.

    基于maven,因为只是用来演示,所以使用的是原生servlet

单Web应用登录,主要涉及到认证、授权、会话建立、取消会话等几个关键环节。推广到多系统,每个系统也会涉及到认证、授权、会话建立取消等工作。那我们能不能把每个系统的认证工作抽象出来,放到单独的服务应用中取处理,是不是就能解决单点登录问题?

思考方向是正确的,我们把这个统一处理认证服务的应用叫认证中心。当用户访问子系统需要登录时,我们把它引到认证中心,让用户到认证中心去登录认证,认证通过后返回并告知系统用户已登录。当用户再访问另一系统应用时,我们同样引导到认证中心,发现已经登录过,即返回并告知该用户已登录。

用户登录:

用户首次登录时流程如下:

1.用户浏览器访问系统A需登录受限资源。

2.系统A发现该请求需要登录,将请求重定向到认证中心,进行登录。

3.认证中心呈现登录页面,用户登录,登录成功后,认证中心重定向请求到系统A,并附上认证通过令牌。

4.系统A与认证中心通信,验证令牌有效,证明用户已登录。

5.系统A将受限资源返给用户。

用户登出:

整个登出流程如下:

1.客户端向应用A发送登出Logout请求。

2.应用A取消本地会话,同时通知认证中心,用户已登出。

3.应用A返回客户端登出请求。

4.认证中心通知所有用户登录访问的应用,用户已登出。

3 接口设计

经过归纳分析一共6个主要接口 4个是服务端的 2个是每个客户端需要实现的接口:

ssoServer接口:

1登录页

/auth/toLogin

描述:进入输入登录信息页面。(通过应用重定向,或者直接访问登录页面)

2登录

/auth/login

描述:登录页面提交的登录请求(账号,密码)。

3令牌验证

/ticket/verify

描述:验证令牌是否有效,应用服务器通过发送HTTP请求来校验令牌是否有效

4登出

/auth/loginOut

描述:退出当前用户的登录状态,同时退出所有子系统(应用服务器)的登录状态。

 

ssoClient(应用服务器)接口:

1登出

/exit

退出登录,重定向到认证中心的退出接口,并携带本应用的首页(认证中心退出成功后重定向的页面)

2用户中心

/main

需要登陆授权的网页

4 核心代码

服务端:

登录controller

package rg.sso.controller;

import java.io.IOException;
import java.security.Principal;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Map.Entry;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.DigestUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.RequestMapping;

import com.mysql.fabric.Response;
import com.mysql.jdbc.interceptors.SessionAssociationInterceptor;

import rg.sso.bean.User;
import rg.sso.cache.GlobalSessionCache;
import rg.sso.cache.redis.RedisCache;
import rg.sso.service.UserService;
import rg.sso.util.ConfigUtil;
import rg.sso.util.CookieUtil;
import rg.sso.util.HttpUtil;
import rg.sso.util.Md5Util;
import rg.sso.util.StringUtil;

/**
 * @Title:AuthController
 * @Description:授权控制器
 * @author 张颖辉
 * @date 2017年9月8日上午10:07:54
 * @version 1.0
 */
@Controller
@RequestMapping("auth")
public class AuthController extends BaseController {
	private String cookieName = "SSO_ACEGI_SECURITY_HASHED_REMEMBER_ME_COOKIE";
	private boolean verifyExpiryTime = Boolean.parseBoolean(ConfigUtil.get("cookie_verifyExpiryTime"));

	@Autowired
	private UserService userService;

	/**
	 * @Title:函数
	 * @Description:进入登录页面
	 * @author 张颖辉
	 * @date 2017年9月8日上午10:08:47
	 * @param service
	 * @param model
	 * @param logname
	 * @param request
	 * @return
	 */
	@RequestMapping("toLogin")
	public String toLogin(String service, Model model,
			@CookieValue(value = "SSO_ACEGI_SECURITY_HASHED_REMEMBER_ME_COOKIE", required = false) String ssoCookieVal,
			HttpServletRequest request, HttpServletResponse response) {
		if (StringUtil.isEmpty(ssoCookieVal)) {// 没有单点登录的cookie
			// 未登录
			logger.debug("SSO未登录,进入登录页面");
			model.addAttribute("service", service);
			return "login";
		} else {// 有单点登录的cookie
			// 校验cookie
			String[] cookieTokens = ssoCookieVal.split(":");
			if (cookieTokens.length == 3) {
				String loginName = cookieTokens[0];
				String expiryTime = cookieTokens[1];// 失效时间
				String secretKey = cookieTokens[2];

				if (verifyExpiryTime && Long.parseLong(expiryTime) < System.currentTimeMillis()) {
					// cookie 已经过期
					logger.debug("SSO-Cookie 已经过期,需要重新登录");
					model.addAttribute("service", service);
					return "login";
				}
				User user = userService.selectUserByLoginName(loginName);
				String secretKeyVal_new = loginName + ":" + expiryTime + ":" + user.getPassword() + ":"
						+ ConfigUtil.get("md5HexKey");
				String secretKey_new = Md5Util.md5AsHex(secretKeyVal_new);

				if (!secretKey_new.equals(secretKey)) {
					// cookie秘钥不正确
					logger.debug("SSO-Cookie秘钥不正确,当前:{}, 应该:{}", secretKey_new, secretKey);
					// 删除sso全局cookie
					logger.debug("删除sso全局cookie");
					CookieUtil.clearVerifyCookie(cookieName, request, response);

					model.addAttribute("service", service);
					return "login";
				}
				// cookie 校验通过,说明已经登录
				if (StringUtil.isUnEmpty(service)) {
					// 其他应用重定向访问
					// ticket
					String ticket = loginName + System.currentTimeMillis();
					RedisCache.getInstance().put(ticket, user, Long.valueOf(ConfigUtil.get("sso_token_maxAge")));
					StringBuilder url = new StringBuilder();
					url.append(service);
					if (0 <= service.indexOf("?")) {
						url.append("&");
					} else {
						url.append("?");
					}
					url.append("ticket=").append(ticket);
					HttpSession session = request.getSession();
					// 在浏览器没有关闭,而服务端重启的时候,session监听器没有监听到session的创建,不能放入全局session缓存。
					// 造成后面的验证令牌时无法获取全局session,出现循环重定向的问题,所以这里要重新放入redis缓存一次。
					if (GlobalSessionCache.getInstance().get(session.getId()) == null) {
						logger.debug("cookie正确,而session缓存中没有此全局会话:{}",session.getId());
						GlobalSessionCache.getInstance().put(session.getId(), session);
					}
					url.append("&globalSessionId=").append(session.getId());
					logger.debug("已经登录(cookie 正确),回跳应用网站:" + url.toString());
					return "redirect:" + url.toString();
				} else {
					// 本应用访问
					logger.debug("已经登录(cookie 正确),直接访问SSO,进入系统");
					return "userCenter";
				}
			} else {
				logger.warn("非法格式的sso-Cookie");
				// 删除sso全局cookie
				logger.debug("删除sso全局cookie");
				CookieUtil.clearVerifyCookie(cookieName, request, response);
				model.addAttribute("service", service);
				return "login";
			}
		}
	}

	/**
	 * @Title:函数
	 * @Description:提交登录请求
	 * @author 张颖辉
	 * @date 2017年9月8日上午10:09:10
	 * @param logname
	 * @param password
	 * @param service
	 * @param request
	 * @param response
	 * @return
	 * @throws IOException
	 */
	@RequestMapping(value = "login")
	public String login(String loginName, String password, String service, HttpServletRequest request,
			HttpServletResponse response) {
		loginName = loginName.trim();
		User user = userService.selectUserByLoginName(loginName);
		String password2md5 = Md5Util.md5AsHex(password);

		if (user != null && user.getPassword().equals(password2md5)) {
			/***** cookie *****/
			// cookie有效时长-秒
			int maxAge = Integer.parseInt(ConfigUtil.get("cookie_expiryTime"));
			// cookie到期的时间
			long expiryTime = System.currentTimeMillis() + maxAge * 1000;
			// 秘钥真实内容
			String secretKeyVal = loginName + ":" + expiryTime + ":" + password2md5 + ":" + ConfigUtil.get("md5HexKey");
			// 生成秘钥
			String secretKey = Md5Util.md5AsHex(secretKeyVal);
			// 生成cookie的值
			String cookieValue = loginName + ":" + expiryTime + ":" + secretKey;
			logger.debug("[登录成功]保存cookie:sso{}={}", cookieName, cookieValue);
			// 创建cookie对象
			Cookie cookie = CookieUtil.makeVerifyCookie(cookieName, cookieValue, request, maxAge, verifyExpiryTime);
			// 添加cookie
			response.addCookie(cookie);
			// 是否是应用服务器重定向而来
			if (StringUtil.isUnEmpty(service)) {
				// ticket
				String ticket = loginName + System.currentTimeMillis();
				RedisCache.getInstance().put(ticket, user, Long.valueOf(ConfigUtil.get("sso_token_maxAge")));
				StringBuilder url = new StringBuilder();
				url.append(service);
				if (0 <= service.indexOf("?")) {
					url.append("&");
				} else {
					url.append("?");
				}
				url.append("ticket=").append(ticket);
				url.append("&globalSessionId=").append(request.getSession().getId());
				logger.debug("登录成功:回跳应用网站:" + url.toString());
				return "redirect:" + url.toString();
			} else {
				// 直接在 SSO 登录
				logger.debug("登录成功:进入SSO用户中心");
				return "userCenter";
			}
		} else {
			logger.debug("登录失败,再次进入登录页面");
			logger.debug("删除sso全局cookie");
			CookieUtil.clearVerifyCookie(cookieName, request, response);
			return "redirect:" + "/auth/toLogin?service=" + service;
		}
	}

	@RequestMapping("loginOut")
	public String loginOut(String server, HttpServletRequest request, HttpServletResponse response) {
		HttpSession httpSession = request.getSession();// 重定向而来
		@SuppressWarnings("unchecked")
		Map<String, String> loginOutMap = (Map<String, String>) httpSession.getAttribute("loginOutMap");// 用户已经登录的应用服务器,map<局部会话id,应用退出接口>
		if (loginOutMap != null) {
			Iterator<Entry<String, String>> iterator = loginOutMap.entrySet().iterator();
			while (iterator.hasNext()) {
				Entry<String, String> entry = iterator.next();
				Map<String, String> params = new HashMap<>();
				params.put("localSessionId", entry.getKey());
				try {
					logger.debug("【【登出】Url:" + entry.getValue());
					if (StringUtil.isUnEmpty(entry.getValue())) {
						HttpUtil.http(entry.getValue(), params);
						// iterator.remove();// 删除已经退出的APP会话信息[没必要]。
					}
				} catch (Exception e) {
					e.printStackTrace();// 打印异常,继续执行循环退出其他应用
				}
			}
		} else {
			logger.warn("从未登陆过或登出会话异常,重启浏览器");
		}
		// 清除sso-cookie
		logger.debug("删除sso全局cookie");
		CookieUtil.clearVerifyCookie(cookieName, request, response);
		httpSession.invalidate();// 销毁全局会话
		// 视图控制
		if (StringUtil.isUnEmpty(server)) {
			// 应用请求而来
			logger.debug("SSO(APP)退出成功,返回到:" + server);
			return "redirect:" + server;
		} else {
			logger.debug("SSO中心直接退出成功");
			return "redirect:toLogin";
		}
	}

}

令牌controller

package rg.sso.controller;

import java.util.HashMap;
import java.util.Map;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import rg.sso.bean.User;
import rg.sso.cache.GlobalSessionCache;
import rg.sso.cache.redis.RedisCache;
import rg.sso.util.Constant;
import rg.sso.util.CookieUtil;
import rg.sso.util.StringUtil;
import rg.sso.vo.Result;

/**
 * @Title:TicketController
 * @Description:令牌控制器
 * @author 张颖辉
 * @date 2017年9月8日上午10:08:23
 * @version 1.0
 */
@Controller
@RequestMapping("ticket")
public class TicketController extends BaseController {
	/**
	 * @Description:验证令牌
	 * @author 张颖辉
	 * @date 2017年9月5日上午10:20:24
	 * @param ticket
	 * @param localSessionId
	 * @param globalSessionId
	 * @param request
	 * @return
	 */
	@ResponseBody
	@RequestMapping("verify")
	public Result<User> verify(String ticket, String localSessionId, String localLoginOutUrl, String globalSessionId,
			HttpServletRequest request) {
		// map = new HashMap<>();
		Result<User> result = new Result<User>();
		User user = (User) RedisCache.getInstance().get(ticket);
		RedisCache.getInstance().remove(ticket);
		if (user != null) {
			HttpSession httpSession = GlobalSessionCache.getInstance().get(globalSessionId);
			if (httpSession == null) {
				// map.put("code", Constant.CODE_FAIL);
				// map.put("msg", "全局会话失效");
				result.setCode(Constant.CODE_FAIL);
				result.setMsg("全局会话失效");
				logger.warn("全局会话失效");
				return result;
			}
			logger.debug("token 令牌认证成功");
			Map<String, String> loginOutMap = (Map<String, String>) httpSession.getAttribute("loginOutMap");// 保存用户已经登录的应用服务器,map<应用退出接口,应用服务器会话id>
			// 保存 [本地会话id:退出接口] 到全局会话
			if (loginOutMap == null) {
				loginOutMap = new HashMap<>();
				httpSession.setAttribute("loginOutMap", loginOutMap);
			}
			loginOutMap.put(localSessionId, localLoginOutUrl);
			// 返回数据
			// map.put("code", Constant.CODE_SUCCESS);
			// map.put("msg", "令牌认证成功!");
			// map.put("globalSessionId", globalSessionId);//
			// 应用发送给SSO退出请求时使用(应该无需返回),之前登录生成令牌回调已经发送了全局会话id
			// map.put("user", user);
			result.setCode(Constant.CODE_SUCCESS);
			result.setMsg("令牌认证成功!");
			result.setData(user);

		} else {
			// map.put("code", Constant.CODE_FAIL);
			// map.put("msg", "令牌认证失败");
			result.setCode(Constant.CODE_FAIL);
			result.setMsg("令牌认证失败!");
			// result.setData(user);
			logger.debug("令牌认证失败,可能的原因:1 令牌已经被使用过,2 错误的令牌");

		}
		return result;
	}
}

受拦截的Controller

package rg.sso.controller;

import java.util.HashMap;
import java.util.Map;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
@RequestMapping("main")
public class MainContruller extends BaseController {
	@RequestMapping("")
	@ResponseBody
	public Map<String, Object> index() {
		map = new HashMap<String, Object>();
		map.put("msg", "这是main主页");
		return map;
	}
}

controller超类:

package rg.sso.controller;

import java.util.Map;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * @Title:BaseController
 * @Description:Controller 超类
 * @author 张颖辉
 * @date 2017年9月4日下午9:46:08
 * @version 1.0
 */
public class BaseController {
	protected Logger logger = LoggerFactory.getLogger(getClass());
	protected Map<String, Object> map = null;
}

由于篇幅的原因请大家直接看源码吧

全局会话缓存

会话监听器

令牌缓存

web.xml 配置

pom依赖

客户端:

客户端核心过滤器

本地会话缓存

本地会话监听器

exit的servlet

web.xml 配置

pom依赖

 

展开阅读全文
加载中
点击加入讨论🔥(3) 发布并加入讨论🔥
打赏
3 评论
1 收藏
0
分享
返回顶部
顶部