先把源码贴出来,再慢慢讲解思路和原理以及实现方式
-->源代码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依赖