首页> 博客> 基于SpringBoot+JWT+Redis跨域单点登录的实现
29 04 2019
一、初识单点登录和JWT

项目中涉及到单点登录,通过各方面了解和学习,本篇就来记录下个人对单点登录的理解和实现;当然对于不同的业务场景,单点登录的实现方式可能不同,但是核心思想应该都是差不多的.....

1.1、什么是单点登录

单点登录SSO(Single Sign On),简单来说,就是多个系统共存的一个大环境中,用户单一位置登录,实现多系统同时登录的一种技术,也就是说,用户的一次登录可以获得其它所有子系统的信任。单点登录在大型网站使用非常频繁,例如阿里巴巴(淘宝)、京东等网站,背后都有成百上千个子系统组成,用户一个操作可能会涉及到几个或更多子系统之间的协作。那你想想,如果每个子系统都需要用户去认证(登录)一次,这种体验是极差的;实现单点登录,实际上就是互相授信的系统之间,解决如何产生和存储这个信任,还有就是如何验证这个信任的有效性(安全);

1.2、什么是Session共享

这个‘含义’是在分布式集群的环境下产生的。也就是摒弃了原系统(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同步更新用户会话信息。

1.3、Session共享就是单点登录吗?

接着上面,我们接下来再来看一下这个图:

这里的 web ServiceA/B/C ,请注意不等同上面的 web service副本1/2/3,它们是不同的三个系统(子系统);此时如果我们还是通过统一管理session的方式实现Session共享的话。当用户登录A系统后,并不能完成自动切换到B系统,这时候他需要再次在B系统中登录认证才行;原因:系统A 和 系统 B 的session id 不一样了;这就说明一个问题:共享Session 并不是单点登录,他不能解决单点登录的问题,但是单点登录就能解决共享Session的问题!

1.4、了解JWT

JWT(JSON Web Token),官网 。它是一种紧凑且自包含的,用于在多方传递JSON对象的技术。传递的数据可以使用数字签名增加其安全行。可以使用HMAC加密算法或RSA公钥/私钥加密方式。

紧凑: 数据小,可以通过URL,POST参数,请求头发送。且数据小代表传输速度快;

自包含: 使用payload数据块记录用户必要且不隐私的数据,可以有效的减少数据库访问次数,提高代码性能;

JWT一般用于处理用户身份验证或数据信息交换;

  • 用户身份验证:一旦用户登录,每个后续请求都将包含JWT,允许用户访问该令牌允许的路由,服务和资源。单点登录是当今广泛使用JWT的一项功能,因为它的开销很小,并且能够轻松地跨不同域使用。

  • 数据信息交换:JWT是一种非常方便的多方传递数据的载体,因为其可以使用数据签名来保证数据的有效性和安全性。

JWT的数据结构是 : A.B.C。 由字符点‘.’来分隔三部分数据。

  • A - header 头信息 数据结构: {“alg”: “加密算法名称”, “typ” : “JWT”} alg是加密算法定义内容,如:HMAC SHA256 或 RSA typ是token类型,这里固定为JWT。

  • B - payload (有效荷载?) 在payload数据块中一般用于记录实体(通常为用户信息)或其他数据的。主要分为三个部分,分别是:已注册信息(registered claims),公开数据(public claims),私有数据(private claims)。 payload中常用信息有:iss(发行者),exp(到期时间),sub(主题),aud(受众)等。前面列举的都是已注册信息。公开数据部分一般都会在JWT注册表中增加定义。避免和已注册信息冲突。公开数据和私有数据可以由程序员任意定义。

注意:

即使JWT有签名加密机制,但是payload内容都是明文记录,除非记录的是加密数据,否则不排除泄露隐私数据的可能。不推荐在payload中记录任何敏感数据。

  • C - Signature 签名 签名信息。这是一个由开发者提供的信息。是服务器验证的传递的数据是否有效安全的标准。在生成JWT最终数据的之前。先使用header中定义的加密算法,将header和payload进行加密,并使用点进行连接。如:加密后的head.加密后的payload。再使用相同的加密算法,对加密后的数据和签名信息进行加密。得到最终结果。

JWT的执行流程

二、实现完全跨域单点登录
2.1、了解什么是跨域

跨域:客户端请求的时候,请求的服务器,不是同一个IP,端口,域名主机名以及请求协议,应当都成为跨域; 域:在应用模型,一个完整的,有独立访问路径的功能集合称为一个域。如:百度称为一个应用或系统。百度下有若干的域,如:搜索引擎(www.baidu.com),百度贴吧(tie.baidu.com),百度知道(zhidao.baidu.com),百度地图(map.baidu.com)等。域信息,有时也称为多级域名。域的划分: 以IP,端口,域名,主机名为标准,实现划分。

2.2、跨域单点登录的实现

先看下图:(图大致画了下,比较粗略)

说明:图中有订单系统(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信息进行校验);校验成功才放行!

2.3、代码实现

上面巴拉巴拉那么多,可能有的地方还是说得不明白,下面直接来看代码:

首先看一下子系统的过滤器(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));
    }
}
三、效果展示
3.1、实现效果截图

这里的 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
以及 尚学堂单点登录教程

类似文章

  1. SpringCloud入门系列之服务链路追踪Sleuth&Zipkin
  2. SpringCloud入门系列之微服务之间的通信
  3. SpringCloud入门系列之API网关
  4. SpringCloud入门系列之配置中心
  5. SpringCloud入门系列之Eureka注册中心

评论区

| 0 评论

还没有评论,快来抢沙发吧!