项目中涉及到单点登录,通过各方面了解和学习,本篇就来记录下个人对单点登录的理解和实现;当然对于不同的业务场景,单点登录的实现方式可能不同,但是核心思想应该都是差不多的.....
单点登录SSO(Single Sign On),简单来说,就是多个系统共存的一个大环境中,用户单一位置登录,实现多系统同时登录的一种技术,也就是说,用户的一次登录可以获得其它所有子系统的信任。单点登录在大型网站使用非常频繁,例如阿里巴巴(淘宝)、京东等网站,背后都有成百上千个子系统组成,用户一个操作可能会涉及到几个或更多子系统之间的协作。那你想想,如果每个子系统都需要用户去认证(登录)一次,这种体验是极差的;实现单点登录,实际上就是互相授信的系统之间,解决如何产生和存储这个信任,还有就是如何验证这个信任的有效性(安全);
这个‘含义’是在分布式集群的环境下产生的。也就是摒弃了原系统(Tomcat)提供的Session,而使用自定义的类似Session的机制来保存客户端数据的一种解决方案。具体了解可以浏览这篇文章
(https://blog.csdn.net/koli6678/article/details/80144702),下面主要通过几个图来理解一下:
传统的单击web系统,部署简单,但是随着用户访问量的增加(并发),一台服务器明显不能够满足庞大的访问量,这时候可以考虑,应用部署多台服务器,实现负载均衡,也就出现了如下这中场景:
web service副本1/2/3 都是同一个系统,只是分别部署在三台服务器上,这样就可以实现用户分流,减轻原来单台服务器的压力。但是这样会有个问题,比如这样的场景:一个用户访问系统,第一次进入系统所在的服务器(web service副本1),当他刷新页面,这时候负载均衡到服务器(web service副本2)上,但是这个服务器上并没有用户信息(session),系统拦截到就需要重新登录认证。那这样就比较尴尬啦.....那么如何解决呢?考虑实现Session共享(同步),
方式一:
用户首次登录负载在tomcat1上,此时保存用户会话信息,同时同步给其它负载服务器tomcat2/3....。这样,当用户由负载 1 到 负载 2 服务器上,由于负载2同步了客户的会话信息给负载1,此时就不需要再次去登录认证了...
说明
方式二:
这种方式与方式一的区别,主要在于各台服务器不通过session同步的机制,而是将session统一的存储在数据库中(mysql/redis),用户会话信息进行统一管理,实现无状态。
这样每次验证的时候,都去redis中去读取Session信息,如果有则放行,反之则需要去登录认证。登录成功后,redis同步更新用户会话信息。
接着上面,我们接下来再来看一下这个图:
这里的 web ServiceA/B/C ,请注意不等同上面的 web service副本1/2/3,它们是不同的三个系统(子系统);此时如果我们还是通过统一管理session的方式实现Session共享的话。当用户登录A系统后,并不能完成自动切换到B系统,这时候他需要再次在B系统中登录认证才行;原因:系统A 和 系统 B 的session id 不一样了;这就说明一个问题:共享Session 并不是单点登录,他不能解决单点登录的问题,但是单点登录就能解决共享Session的问题!
JWT(JSON Web Token),官网 。它是一种紧凑且自包含的,用于在多方传递JSON对象的技术。传递的数据可以使用数字签名增加其安全行。可以使用HMAC加密算法或RSA公钥/私钥加密方式。
紧凑: 数据小,可以通过URL,POST参数,请求头发送。且数据小代表传输速度快;
自包含: 使用payload数据块记录用户必要且不隐私的数据,可以有效的减少数据库访问次数,提高代码性能;
JWT一般用于处理用户身份验证或数据信息交换;
JWT的数据结构是 : A.B.C。 由字符点‘.’来分隔三部分数据。
注意:
即使JWT有签名加密机制,但是payload内容都是明文记录,除非记录的是加密数据,否则不排除泄露隐私数据的可能。不推荐在payload中记录任何敏感数据。
JWT的执行流程
跨域:客户端请求的时候,请求的服务器,不是同一个IP,端口,域名主机名以及请求协议,应当都成为跨域; 域:在应用模型,一个完整的,有独立访问路径的功能集合称为一个域。如:百度称为一个应用或系统。百度下有若干的域,如:搜索引擎(www.baidu.com),百度贴吧(tie.baidu.com),百度知道(zhidao.baidu.com),百度地图(map.baidu.com)等。域信息,有时也称为多级域名。域的划分: 以IP,端口,域名,主机名为标准,实现划分。
先看下图:(图大致画了下,比较粗略)
说明:图中有订单系统(order.demo1.com)、vip系统(vip.demo2.com)等子系统,当用户访问订单系统的时候,先会从cookie中获取token信息,如果token信息是空的,则携带访问的url(redirectURL)和设置客户端cookie的url(setCookieUrl)一同重定向到统一认证中心(sso.demo.com),进行统一登录验证;然后,用户登录成功后,认证中心会生成该用户的token信息,并保存到cookie中,同时也将用户信息存入redis缓存中(key=login:+生成的token,value=用户信息),设置有效时间;信息成功保存后,则重定向到携带过来的(setCookieUrl),redirectUrl和产生的token(作为参数)一并带过去;此时从认证中心来到订单系统,拦截到SetCookie的uri,则去设置cookie,将token信息存入cookie;之后再重定向到携带过来的(redirect_url)同时携带token;
每次访问子系统uri都会对token进行校验(1.判断token是否有效或存在;2.token存在也不一定表示有效,还需要通过模拟http请求,这里使用httpclient post请求 sso认证中心对token信息进行校验);校验成功才放行!
上面巴拉巴拉那么多,可能有的地方还是说得不明白,下面直接来看代码:
首先看一下子系统的过滤器(SsoFilter)
package com.xmlvhy.order.filter;
import com.xmlvhy.order.utils.CookiesUtil;
import com.xmlvhy.order.utils.HttpUtil;
import lombok.extern.slf4j.Slf4j;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Map;
/**
* @ClassName SsoFilter
* @Description TODO:sso服务过滤器
* @Author 小莫
* @Date 2019/04/20 16:51
* @Version 1.0
**/
@Slf4j
public class SsoFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
log.info("============== doFilter ==============");
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
//获取url
String url = request.getScheme() + "://" + request.getServerName() + ":" + request.getServerPort() + request.getRequestURI();
String path = request.getContextPath();
String redirectURL = request.getParameter("redirect_url");
if (redirectURL == null) {
redirectURL = url;
}
//本地
String ssoServerURL = "http://sso.demo.com:8083/ssoAuth";
String ssoUrl = ssoServerURL + "/auth/preLogin?setCookieURL="
+ request.getScheme() + "://"
+ request.getServerName() + ":" + request.getServerPort()
+ path + "/setCookie&redirect_url=" + redirectURL;
String token = CookiesUtil.getCookieValue(request, "token");
log.info("uri================>{}", request.getRequestURI());
log.info("path=================>{}", path);
log.info("redirectURL=========>{}", redirectURL);
log.info("token=========>{}", token);
if (request.getRequestURI().equals(path + "/logout")) {
//退出登录
doLogout(ssoServerURL,token,request,response);
} else if (request.getRequestURI().equals(path + "/modifyPass")) {
//修改密码
doModifyPass(ssoServerURL,ssoUrl,token,path,request,response);
} else if (request.getRequestURI().equals(path + "/setCookie")) {
//客户端设置cookie
doSetCookie(redirectURL,request,response);
} else if (token != null || token != "") {
//有Token也未必登录了,有可能token已经过期,通过httpClient请求去获取信息
doCheckUser(ssoServerURL,ssoUrl,token,request,response,filterChain);
} else {
response.sendRedirect(ssoUrl);
return;
}
}
/**
*功能描述: 退出登录
* @Author 小莫
* @Date 20:38 2019/04/27
* @Param [ssoServerURL, token, request, response]
* @return void
*/
private void doLogout(String ssoServerURL,String token,HttpServletRequest request,HttpServletResponse response) throws IOException {
Map<String, Object> LogoutRet = HttpUtil.doPost(ssoServerURL + "/auth/user/logout?token=" + token, null, 4000);
if (LogoutRet == null || LogoutRet.isEmpty()) {
log.warn("退出登录出错");
}
log.info("退出登录,返回信息:{}", LogoutRet);
//清楚客户端cookie
CookiesUtil.deleteCookie(request, response, "token");
response.sendRedirect(ssoServerURL + "/auth/success/logout");
return;
}
/**
*功能描述: 修改密码
* @Author 小莫
* @Date 20:38 2019/04/27
* @Param [ssoServerURL, ssoUrl, token, path, request, response]
* @return void
*/
private void doModifyPass(String ssoServerURL,String ssoUrl,String token,String path,
HttpServletRequest request,HttpServletResponse response) throws IOException {
if (token != "") {
//重置密码成功后,会访问主页,此时用户信息已更新则会自动跳转到登录页
String bk = request.getScheme() + "://" + request.getServerName() + ":" + request.getServerPort() + path + "/index";
response.sendRedirect(ssoServerURL + "/auth/modifyPass?token=" + token + "&redirect_url=" + bk);
return;
}
response.sendRedirect(ssoUrl);
return;
}
/**
*功能描述: 设置客户端cookie
* @Author 小莫
* @Date 20:38 2019/04/27
* @Param [redirectURL, request, response]
* @return void
*/
private void doSetCookie(String redirectURL,HttpServletRequest request,HttpServletResponse response) throws IOException {
CookiesUtil.setCookie(request, response, "token", request.getParameter("token"), 0, true);
if (redirectURL != null) {
//跳转到页面
response.sendRedirect(redirectURL + "?token=" + request.getParameter("token"));
return;
}
}
/**
*功能描述: 校验用户信息
* @Author 小莫
* @Date 20:39 2019/04/27
* @Param [ssoServerURL, ssoUrl, token, request, response, filterChain]
* @return void
*/
private void doCheckUser(String ssoServerURL,String ssoUrl, String token,
HttpServletRequest request,HttpServletResponse response,FilterChain filterChain) throws IOException, ServletException {
Map<String, Object> ret = HttpUtil.doPost(ssoServerURL + "/auth/user/info/" + token, null, 4000);
if (ret == null || ret.isEmpty()) {
//服务器token或cookie失效,子系统应该也要把cookie清除
CookiesUtil.deleteCookie(request, response, "token");
response.sendRedirect(ssoUrl);
return;
}
request.setAttribute("userInfo", ret);
filterChain.doFilter(request,response);
}
@Override
public void destroy() {
}
}
package com.xmlvhy.order.config;
import com.xmlvhy.order.filter.SsoFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @ClassName FilterConfig
* @Description TODO 过滤器配置类
* @Author 小莫
* @Date 2019/04/20 22:02
* @Version 1.0
**/
@Configuration
public class FilterConfig {
@Bean
public FilterRegistrationBean registrationBean(){
FilterRegistrationBean bean = new FilterRegistrationBean();
bean.setFilter(new SsoFilter());
bean.addUrlPatterns("/*");
bean.setName("ssoOrderFilter");
bean.setOrder(1);
return bean;
}
}
工具类:CookiesUtil.java
package com.xmlvhy.order.utils;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.net.URLEncoder;
/**
* @ClassName CookiesUtil
* @Description TODO
* @Author 小莫
* @Date 2019/04/20 16:49
* @Version 1.0
**/
public class CookiesUtil {
/**
* 得到Cookie的值, 不编码
*
* @param request
* @param cookieName
* @return
*/
public static String getCookieValue(HttpServletRequest request, String cookieName) {
return getCookieValue(request, cookieName, false);
}
/**
* 得到Cookie的值,
*
* @param request
* @param cookieName
* @return
*/
public static String getCookieValue(HttpServletRequest request, String cookieName, boolean isDecoder) {
Cookie[] cookieList = request.getCookies();
if (cookieList == null || cookieName == null) {
return null;
}
String retValue = null;
try {
for (int i = 0; i < cookieList.length; i++) {
if (cookieList[i].getName().equals(cookieName)) {
if (isDecoder) {
retValue = URLDecoder.decode(cookieList[i].getValue(), "UTF-8");
} else {
retValue = cookieList[i].getValue();
}
break;
}
}
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
return retValue;
}
/**
* 得到Cookie的值,
*
* @param request
* @param cookieName
* @return
*/
public static String getCookieValue(HttpServletRequest request, String cookieName, String encodeString) {
Cookie[] cookieList = request.getCookies();
if (cookieList == null || cookieName == null) {
return null;
}
String retValue = null;
try {
for (int i = 0; i < cookieList.length; i++) {
if (cookieList[i].getName().equals(cookieName)) {
retValue = URLDecoder.decode(cookieList[i].getValue(), encodeString);
break;
}
}
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
return retValue;
}
/**
* 设置Cookie的值 不设置生效时间默认浏览器关闭即失效,也不编码
*/
public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName,
String cookieValue) {
setCookie(request, response, cookieName, cookieValue, -1);
}
/**
* 设置Cookie的值 在指定时间内生效,但不编码
*/
public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName,
String cookieValue, int cookieMaxage) {
setCookie(request, response, cookieName, cookieValue, cookieMaxage, false);
}
/**
* 设置Cookie的值 不设置生效时间,但编码
*/
public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName,
String cookieValue, boolean isEncode) {
setCookie(request, response, cookieName, cookieValue, -1, isEncode);
}
/**
* 设置Cookie的值 在指定时间内生效, 编码参数
*/
public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName,
String cookieValue, int cookieMaxage, boolean isEncode) {
doSetCookie(request, response, cookieName, cookieValue, cookieMaxage, isEncode);
}
/**
* 设置Cookie的值 在指定时间内生效, 编码参数(指定编码)
*/
public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName,
String cookieValue, int cookieMaxage, String encodeString) {
doSetCookie(request, response, cookieName, cookieValue, cookieMaxage, encodeString);
}
/**
* 删除Cookie带cookie域名
*/
public static void deleteCookie(HttpServletRequest request, HttpServletResponse response,
String cookieName) {
doSetCookie(request, response, cookieName, "", -1, false);
}
/**
* 设置Cookie的值,并使其在指定时间内生效
*
* @param cookieMaxage cookie生效的最大秒数
*/
private static final void doSetCookie(HttpServletRequest request, HttpServletResponse response,
String cookieName, String cookieValue, int cookieMaxage, boolean isEncode) {
try {
if (cookieValue == null) {
cookieValue = "";
} else if (isEncode) {
cookieValue = URLEncoder.encode(cookieValue, "utf-8");
}
Cookie cookie = new Cookie(cookieName, cookieValue);
if (cookieMaxage > 0)
cookie.setMaxAge(cookieMaxage);
if (null != request) {// 设置域名的cookie
String domainName = getDomainName(request);
if (!"localhost".equals(domainName)) {
cookie.setDomain(domainName);
}
}
cookie.setPath("/");
response.addCookie(cookie);
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 设置Cookie的值,并使其在指定时间内生效
*
* @param cookieMaxage cookie生效的最大秒数
*/
private static final void doSetCookie(HttpServletRequest request, HttpServletResponse response,
String cookieName, String cookieValue, int cookieMaxage, String encodeString) {
try {
if (cookieValue == null) {
cookieValue = "";
} else {
cookieValue = URLEncoder.encode(cookieValue, encodeString);
}
Cookie cookie = new Cookie(cookieName, cookieValue);
if (cookieMaxage > 0)
cookie.setMaxAge(cookieMaxage);
if (null != request) {// 设置域名的cookie
String domainName = getDomainName(request);
if (!"localhost".equals(domainName)) {
cookie.setDomain(domainName);
}
}
cookie.setPath("/");
response.addCookie(cookie);
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 得到cookie的域名
*/
private static final String getDomainName(HttpServletRequest request) {
String domainName = null;
// 获取完整的请求URL地址。
String serverName = request.getRequestURL().toString();
if (serverName == null || serverName.equals("")) {
domainName = "";
} else {
serverName = serverName.toLowerCase();
if (serverName.startsWith("http://")){
serverName = serverName.substring(7);
} else if (serverName.startsWith("https://")){
serverName = serverName.substring(8);
}
//这里有可能域名只有,例如: jwt.io ,spring.io,那么这样end为-1
final int end = serverName.indexOf("/");
if (end != -1) {
// .test.com www.test.com.cn/sso.test.com.cn/.test.com.cn spring.io/xxxx/xxx
serverName = serverName.substring(0, end);
}
final String[] domains = serverName.split("\\.");
int len = domains.length;
if (len > 3) {
//spring boot api 不支持这样的domain格式,当然也可以配置
//参考:https://blog.csdn.net/doctor_who2004/article/details/81750713
//domainName = "." + domains[len - 3] + "." + domains[len - 2] + "." + domains[len - 1];
domainName = domains[len - 3] + "." + domains[len - 2] + "." + domains[len - 1];
} else if (len <= 3 && len > 1) {
//domainName = "." + domains[len - 2] + "." + domains[len - 1];
domainName = domains[len - 2] + "." + domains[len - 1];
} else {
domainName = serverName;
}
}
if (domainName != null && domainName.indexOf(":") > 0) {
String[] ary = domainName.split("\\:");
domainName = ary[0];
}
return domainName;
}
}
工具类:HttpUtil.java
package com.xmlvhy.order.utils;
import com.google.gson.Gson;
import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.util.EntityUtils;
import java.util.HashMap;
import java.util.Map;
/**
* 封装 httpClient get post
*/
public class HttpUtil {
private static final Gson gson = new Gson();
/**
* get方法
* @param url
* @return
*/
public static Map<String,Object> doGet(String url){
Map<String,Object> map = new HashMap<>();
CloseableHttpClient httpClient = HttpClients.createDefault();
//设置参数
RequestConfig requestConfig = RequestConfig.custom().setConnectTimeout(5000) //连接超时
.setConnectionRequestTimeout(5000)//请求超时
.setSocketTimeout(5000)
.setRedirectsEnabled(true) //允许自动重定向
.build();
HttpGet httpGet = new HttpGet(url);
httpGet.setConfig(requestConfig);
try{
// 获取请求响应结果
HttpResponse httpResponse = httpClient.execute(httpGet);
if(httpResponse.getStatusLine().getStatusCode() == 200){
//这里注意设置默认字节编码格式 为 utf-8
String jsonResult = EntityUtils.toString(httpResponse.getEntity(),"UTF-8");
map = gson.fromJson(jsonResult,map.getClass());
}
}catch (Exception e){
e.printStackTrace();
}finally {
try {
httpClient.close();
}catch (Exception e){
e.printStackTrace();
}
}
return map;
}
/**
* 封装post
* @return
*/
public static Map<String, Object> doPost(String url, String data, int timeout){
Map<String,Object> map = new HashMap<>();
CloseableHttpClient httpClient = HttpClients.createDefault();
//超时设置
RequestConfig requestConfig = RequestConfig.custom().setConnectTimeout(timeout) //连接超时
.setConnectionRequestTimeout(timeout)//请求超时
.setSocketTimeout(timeout)
.setRedirectsEnabled(true) //允许自动重定向
.build();
HttpPost httpPost = new HttpPost(url);
httpPost.setConfig(requestConfig);
httpPost.addHeader("Content-Type","text/html; charset=UTF-8");
if(data != null && data instanceof String){ //使用字符串传参
StringEntity stringEntity = new StringEntity(data,"UTF-8");
httpPost.setEntity(stringEntity);
}
try{
CloseableHttpResponse httpResponse = httpClient.execute(httpPost);
HttpEntity httpEntity = httpResponse.getEntity();
if(httpResponse.getStatusLine().getStatusCode() == 200){
String result = EntityUtils.toString(httpEntity,"utf-8");
map = gson.fromJson(result,map.getClass());
return map;
}
}catch (Exception e){
e.printStackTrace();
}finally {
try{
httpClient.close();
}catch (Exception e){
e.printStackTrace();
}
}
return null;
}
}
接下来我们来看看,认证中心SSO的核心代码:
package com.zhly.sso.auth.controller;
import com.zhly.sso.auth.entity.ResponseResult;
import com.zhly.sso.auth.entity.User;
import com.zhly.sso.auth.service.SsoAuthService;
import com.zhly.sso.auth.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Map;
/**
* @ClassName SSOAuthController
* @Description TODO: sso 认证中心控制器
* @Author 小莫
* @Date 2019/04/24 19:29
* @Version 1.0
**/
@Controller
@RequestMapping("/auth")
@Slf4j
public class SSOAuthController {
@Autowired
private SsoAuthService authService;
@Autowired
private UserService userService;
/**
*功能描述: 统一登录跳转页面
* @Author 小莫
* @Date 16:19 2019/04/25
* @Param [redirect_url, setCookieURL]
* @return org.springframework.web.servlet.ModelAndView
*/
@RequestMapping(value = "login", method = RequestMethod.GET)
public ModelAndView index(@RequestParam(value = "redirect_url",required = true) String redirect_url,
@RequestParam(value = "setCookieURL",required = true) String setCookieURL) {
ModelAndView mav = new ModelAndView("login");
mav.addObject("redirect_url", redirect_url);
mav.addObject("setCookieURL", setCookieURL);
log.info("login=====> redirectUrl: {}, setCookieUrl: {}", redirect_url, setCookieURL);
return mav;
}
/**
*功能描述: 预登陆,先获取该用户是否已经登录过,如果登录过则去设置客户端cookie
* @Author 小莫
* @Date 15:37 2019/04/25
* @Param [request, response]
* @return java.lang.String
*/
@RequestMapping("preLogin")
public String preLogin(HttpServletRequest request, HttpServletResponse response) {
String ssoServerURL = authService.preLogin(request, response);
return "redirect:" + ssoServerURL;
}
/**
*功能描述: 用户统一登录
* @Author 小莫
* @Date 16:09 2019/04/25
* @Param [username, password, redirect_url, setCookieURL, response, request]
* @return java.lang.String
*/
@RequestMapping(value = "login", method = RequestMethod.POST)
@ResponseBody
public ResponseResult login(String username, String password,
String redirect_url, String setCookieURL,
HttpServletResponse response, HttpServletRequest request) {
return authService.ssoLogin(username,password,redirect_url,setCookieURL,request,response);
}
/**
*功能描述: 获取登录用户的信息(校验)
* @Author 小莫
* @Date 16:16 2019/04/25
* @Param [token]
* @return com.zhly.sso.auth.entity.ResponseResult
*/
@PostMapping("/user/info/{token}")
@ResponseBody
public Object getLoginUserInfo(@PathVariable("token") String token){
return authService.checkUserInfo(token);
}
/**
*功能描述: 用户统一登出
* @Author 小莫
* @Date 16:19 2019/04/25
* @Param [token, response, request]
* @return com.zhly.sso.auth.entity.ResponseResult
*/
@PostMapping("/user/logout")
@ResponseBody
public Map logout(@RequestParam(value = "token",required = true) String token, HttpServletResponse response, HttpServletRequest request) {
return authService.logout(token,request,response);
}
/**
*功能描述: 统一修改密码页面
* @Author 小莫
* @Date 12:31 2019/04/27
* @Param [token]
* @return org.springframework.web.servlet.ModelAndView
*/
@RequestMapping(value = "modifyPass",method = RequestMethod.GET)
public ModelAndView modifyPass(@RequestParam(value = "token",required = true) String token,
@RequestParam(value = "redirect_url",required = true) String redirect_url){
ModelAndView mav = new ModelAndView("modifyPass");
User user = userService.getUserInfo(token);
mav.addObject("userInfo",user);
mav.addObject("token",token);
mav.addObject("redirect_url",redirect_url);
return mav;
}
/**
*功能描述: 处理统一修改密码
* @Author 小莫
* @Date 12:31 2019/04/27
* @Param [password, username, oldPass]
* @return com.zhly.sso.auth.entity.ResponseResult
*/
@RequestMapping(value = "modifyPass",method = RequestMethod.POST)
@ResponseBody
public ResponseResult modifyPass(String password,String username,String oldPass,String token,
HttpServletRequest request,HttpServletResponse response){
return authService.modifyPass(password,username,oldPass,token,request,response);
}
/**
*功能描述: 用户登出成功页面
* @Author 小莫
* @Date 16:22 2019/04/25
* @Param []
* @return java.lang.String
*/
@RequestMapping("/success/logout")
public String allLogout(){
return "logout";
}
}
package com.zhly.sso.auth.service.impl;
import com.zhly.sso.auth.constant.CommonConstant;
import com.zhly.sso.auth.entity.ResponseResult;
import com.zhly.sso.auth.entity.User;
import com.zhly.sso.auth.service.SsoAuthService;
import com.zhly.sso.auth.service.UserService;
import com.zhly.sso.auth.utils.CookiesUtil;
import com.zhly.sso.auth.utils.JwtUtil;
import com.zhly.sso.auth.utils.RedisUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.HashMap;
import java.util.Map;
/**
* @ClassName SsoAuthServiceImpl
* @Description TODO
* @Author 小莫
* @Date 2019/04/27 19:06
* @Version 1.0
**/
@Service
@Slf4j
public class SsoAuthServiceImpl implements SsoAuthService {
@Autowired
private UserService userService;
@Autowired
private RedisUtil redisUtil;
@Value("${sso.login_url}")
private String ssoUrl;
@Value("${sso.token_expire}")
private Long expireTime;
@Override
public String preLogin(HttpServletRequest request, HttpServletResponse response) {
log.info("========== 进入 preLogin =======");
String redirect_url = request.getParameter("redirect_url");
String setCookieURL = request.getParameter("setCookieURL");
String token = CookiesUtil.getCookieValue(request,"token");
String ssoServerURL = ssoUrl + "?setCookieURL="+setCookieURL+"&redirect_url="+redirect_url;
log.info("preLogin=========> redirectUrl: {}, setCookieUrl: {}, token: {}",redirect_url,setCookieURL,token);
if (token == null || token == "") {
//表示未登录过,跳转到登录页面
return ssoServerURL;
} else {
//检验是否有效
User user = (User) redisUtil.get(CommonConstant.REDIS_PRE__KEY + token);
if (user != null) {
//去设置客户端的cookie
if (setCookieURL != null) {
return setCookieURL + "?token=" + token + "&redirect_url=" + redirect_url;
}
}
}
log.info("preLogin===========> soUrl: {}",ssoServerURL);
return ssoServerURL;
}
@Transactional(propagation = Propagation.SUPPORTS,readOnly = true)
@Override
public ResponseResult ssoLogin(String username, String password, String redirect_url, String setCookieURL, HttpServletRequest request, HttpServletResponse response) {
log.info("===== 进入 ssoLogin ====");
String ssoServerURL = ssoUrl + "?setCookieURL="+setCookieURL+"&redirect_url="+redirect_url;
ResponseResult result = userService.login(username, password);
log.info("ssoLogin =====> result:{}",result);
Map<String,Object> ret = new HashMap<>();
if (result.getCode() == 0) {
//登录成功,生成一个token
User user = (User) result.getData();
String token = JwtUtil.generateJWT(user.getId(), user.getUsername());
//写入cookie
CookiesUtil.setCookie(request,response,"token",token,0,true);
//写入redis缓存中,并设置有效时间
user.setPassword(null);
redisUtil.set(CommonConstant.REDIS_PRE__KEY + token,user,expireTime);
String url = setCookieURL + "?token=" + token + "&redirect_url=" + redirect_url;
ret.put("successURL", url);
result.setData(ret);
log.info("ssoLogin =====> successURL: {}",url);
//登录成功后,sso中心保存token,跳转到客户端去保存token 到 cookie 中
return result;
}else{
//登录失败,跳转到登录页面
ret.put("failURL",ssoServerURL);
result.setData(ret);
log.info("ssoLogin =====> failURL: {}",ssoServerURL);
return result;
}
}
@Override
public Object checkUserInfo(String token) {
log.info("===== 进入 checkUserInfo =====");
User user = (User) redisUtil.get(CommonConstant.REDIS_PRE__KEY + token);
log.info("getLoginUserInfo ========> token:{}",token);
return user;
}
@Override
public Map<String, Object> logout(String token, HttpServletRequest request, HttpServletResponse response) {
log.info("logout========> token:{}",token);
// 指定允许其他域名访问
response.setHeader("Access-Control-Allow-Origin", "*");
// 响应类型
response.setHeader("Access-Control-Allow-Methods", "POST");
// 响应头设置
response.setHeader("Access-Control-Allow-Headers", "x-requested-with,content-type");
redisUtil.del("TOKEN_" + token);
//清除cookie
CookiesUtil.deleteCookie(request, response, "token");
//封装 httpclient 请求响应消息
Map ret = new HashMap();
ret.put("code", 200);
ret.put("msg", "ok");
return ret;
}
@Override
public ResponseResult modifyPass(String password, String username, String oldPass, String token, HttpServletRequest request, HttpServletResponse response) {
ResponseResult ret = userService.modifyUserPwd(password, username, oldPass);
if (ret.getCode() == 0) {
//表示成功,这里把原来的缓存和cookie清除
redisUtil.del(CommonConstant.REDIS_PRE__KEY + token);
//清除cookie
CookiesUtil.deleteCookie(request, response, "token");
log.info("密码修改完成,清除cookie和缓存");
}
return ret;
}
}
SSO认证中心相关工具类:
JwtUtil.java
package com.zhly.sso.auth.utils;
import com.alibaba.fastjson.JSONObject;
import com.zhly.sso.auth.constant.CommonConstant;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import javax.crypto.spec.SecretKeySpec;
import javax.xml.bind.DatatypeConverter;
import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
/**
* @ClassName JwtUtil
* @Description TODO: jwt 工具类,用于生成token,用户授权和信息校验
* @Author 小莫
* @Date 2019/04/24 10:37
* @Version 1.0
* 参考jwt官网 https://jwt.io
* jwt 的结构,A.B.C三部分,由字符‘.’分割成三部分数据
* A-header头信息,内容:{"alg":"HS256","typ","JWT"}
* B-payload 有效负荷,一般用于记录实体(常用用户信息),分为
* 三个部分:已注册信息(registered claims)/公开数据(public claims)/私有数据(private claims)
* 常用信息:iss(发行者)、exp(到期时间)、sub(主体)、aud(受众);由于payload是明文暴露的,推荐不要存放隐私数据
* C-signature 签名信息:是将header 和 payload进行加密生成的
**/
@Slf4j
public class JwtUtil {
/**
* 功能描述: 签发 JWT ,也就是生成token
*
* @return java.lang.String
* @Author 小莫
* @Date 11:51 2019/04/24
* @Param [userId, userName, identities]
* 用户编号(id)/用户名
* 格式:A.B.C
* A-header头信息
* B-payload 有效负荷
* C-signature 签名信息 是将header和payload进行加密生成的
*/
public static String generateJWT(Integer userId, String userName) {
//签名算法,选择SHA-256
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
//获取当前系统时间
long nowTimeMillis = System.currentTimeMillis();
Date now = new Date(nowTimeMillis);
//将BASE64SECRET常量字符串使用base64解码成字节数组
byte[] apiKeySecretBytes = DatatypeConverter.parseBase64Binary(CommonConstant.BASE64_SECRET);
//使用HmacSHA256签名算法生成一个HS256的签名秘钥Key
Key signingKey = new SecretKeySpec(apiKeySecretBytes, signatureAlgorithm.getJcaName());
//添加构成JWT的参数
Map<String, Object> headMap = new HashMap<>();
headMap.put("alg", SignatureAlgorithm.HS256.getValue());
headMap.put("typ", "JWT");
JwtBuilder builder = Jwts.builder().setHeader(headMap)
//加密后的客户编号
.claim("userId", AESSecretUtil.encryptToStr(String.valueOf(userId), CommonConstant.AES_SECRET_KEY))
//客户名称
.claim("userName", userName)
//Signature
.signWith(signatureAlgorithm, signingKey);
//添加Token过期时间
if (CommonConstant.EXPIRE_TIME >= 0) {
long expMillis = nowTimeMillis + CommonConstant.EXPIRE_TIME;
Date expDate = new Date(expMillis);
builder.setExpiration(expDate).setNotBefore(now);
}
return builder.compact();
}
/**
* 功能描述: 签发 JWT ,也就是生成token
*
* @return java.lang.String
* @Author 小莫
* @Date 11:51 2019/04/24
* @Param [userId, userName, identities]
* 用户编号(id)/用户名/客户端信息目前包括浏览器信息,用户客户端拦截校验,防止跨域非法访问
* 格式:A.B.C
* A-header头信息
* B-payload 有效负荷
* C-signature 签名信息 是将header和payload进行加密生成的
*/
public static String generateJWT(Integer userId, String userName, String... identities) {
//签名算法,选择SHA-256
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
//获取当前系统时间
long nowTimeMillis = System.currentTimeMillis();
Date now = new Date(nowTimeMillis);
//将BASE64SECRET常量字符串使用base64解码成字节数组
byte[] apiKeySecretBytes = DatatypeConverter.parseBase64Binary(CommonConstant.BASE64_SECRET);
//使用HmacSHA256签名算法生成一个HS256的签名秘钥Key
Key signingKey = new SecretKeySpec(apiKeySecretBytes, signatureAlgorithm.getJcaName());
//添加构成JWT的参数
Map<String, Object> headMap = new HashMap<>();
headMap.put("alg", SignatureAlgorithm.HS256.getValue());
headMap.put("typ", "JWT");
JwtBuilder builder = Jwts.builder().setHeader(headMap)
//加密后的客户编号
.claim("userId", AESSecretUtil.encryptToStr(String.valueOf(userId), CommonConstant.AES_SECRET_KEY))
//客户名称
.claim("userName", userName)
//客户端浏览器信息
.claim("userAgent", identities[0])
//Signature
.signWith(signatureAlgorithm, signingKey);
//添加Token过期时间
if (CommonConstant.EXPIRE_TIME >= 0) {
long expMillis = nowTimeMillis + CommonConstant.EXPIRE_TIME;
Date expDate = new Date(expMillis);
builder.setExpiration(expDate).setNotBefore(now);
}
return builder.compact();
}
/**
* 功能描述: 解析JWT
*
* @return io.jsonwebtoken.Claims 返回 Claims对象
* @Author 小莫
* @Date 11:58 2019/04/24
* @Param [token] JWT生成的token
*/
public static Claims parseJWT(String token) {
Claims claims = null;
try {
if (StringUtils.isNotBlank(token)) {
//解析jwt
claims = Jwts.parser().setSigningKey(DatatypeConverter.parseBase64Binary(CommonConstant.BASE64_SECRET))
.parseClaimsJws(token).getBody();
} else {
log.warn("[JWTUtil]-json web token 为空");
}
} catch (Exception e) {
log.error("[JWTUtil]-JWT解析异常:可能因为token已经超时或非法token");
}
return claims;
}
/**
*功能描述: 校验 token 是否有效
* @Author 小莫
* @Date 12:04 2019/04/24
* @Param [jsonWebToken]
* @return java.lang.String
* 返回json字符串:
* {"freshToken":"A.B.C","userName":"Judy","userId":"123", "userAgent":"xxxx"}
* -freshToken:刷新后的新JWT(token)
* -userName: 客户名称
* - userId: 客户编号
* - userAgent: 客户端浏览器信息
*/
public static String validateLogin(String token) {
Map<String, Object> retMap = null;
Claims claims = parseJWT(token);
if (claims != null) {
//解密客户编号
Integer decryptUserId = Integer.valueOf(AESSecretUtil.decryptToStr(String.valueOf(claims.get("userId")), CommonConstant.AES_SECRET_KEY));
retMap = new HashMap<>();
//解密后的客户编号
retMap.put("userId", decryptUserId);
//客户名称
retMap.put("userName", claims.get("userName"));
//客户端浏览器信息
retMap.put("userAgent", claims.get("userAgent"));
//刷新 JWT
retMap.put("freshToken", generateJWT(decryptUserId, (String)claims.get("userName"), (String)claims.get("userAgent"), (String)claims.get("domainName")));
}else {
log.warn("[JWTUtil]-JWT解析出claims为空");
}
return retMap != null ? JSONObject.toJSONString(retMap) : null;
}
public static void main(String[] args) {
String token = generateJWT(123, "XiaoMo",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36");
System.out.println("生成的jwt: "+token);
System.out.println("==========================");
Claims claims = parseJWT(token);
System.out.println("claims: "+claims);
System.out.println("校验后产生的新的jwt: "+ validateLogin(token));
}
}
这里的 sso.demo.com、vip.demo2.com、order.demo1.com 三个不同的域名都是本地映射模拟的,分别对应SSO认证中心,VIP中心以及订单中心。
方法:修改本地 hosts 文件,我的是win10系统,路径:C:\Windows\System32\drivers\etc
127.0.0.1 sso.demo.com
127.0.0.1 vip.demo2.com
127.0.0.1 order.demo1.com
分别启动 vip、order、sso三个应用:
这时候我们浏览器地址栏,输入:order.demo1.com:8081/index
,检测未登录,统一到认证中心进行登录。
登录成功,进入订单中心:
点击vip会员中心,进入vip系统页面:
在订单中心中,点击修改密码,进入修改密码页面:
说明:
在这过程中,只经过一次的登录验证(第一次进入订单中心的时候),当我点击进入vip系统就不需要再次进行登录了。实现了单一地点登录(order.demo1.com),全系统有效。这就实现了完全跨域的单点系统!
当然,你可以尝试配置多个子系统验证。各个子系统配置好 SsoFilter (过滤器)即可,你也可以通过拦截器来实现。
以上便是我开发跨域单点登录的实现方式,当然后续还要进一步考虑,伪装一下url信息、token的安全性等...
参考
https://www.jianshu.com/p/023a94df16ea
https://www.cnblogs.com/LUA123/p/10126881.html
以及 尚学堂单点登录教程
本文作者: AI码真香
本文标题: 基于SpringBoot+JWT+Redis跨域单点登录的实现
本文网址: https://www.xmlvhy.com/article/64.html
版权说明: 自由转载-非商用-非衍生-保持署名 署名-非商业性使用4.0 国际 (CC BY-NC 4.0)