# 单点登录技术预研

author:徐振东

createTime:2022-04-11

updateTime:2022-04-11


2022-04-11: 培训结束

# 何为单点登录

在百度百科中,单点登录的定义是这样的:单点登录(Single Sign On),简称为 SSO,是比较流行的企业业务整合的解决方案之一。SSO的定义是在多个应用 (opens new window)系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。

# 主流单点登录的解决方案

在讨论单点登录前,我们先来看一下单体应用中是如何实现登录功能的:

​ 在早期客户端与服务端交互的过程中,由于Http的无状态,server 并不清楚前后接收到的两次请求是来自于同一用户还是不同用户,所以用户也只能进行简单的浏览访问,无法与服务端进行深层次的交互,服务端也无法向各个用户提供个性化的服务。随着社会的发展,这种原始的交互模式已经无法满足人们日益增长的需求了。所以后面就出现了cookie和session技术,这两者都可以看作是一个简单的存储数据的容器,可存储各种key、value数据,区别是一个存储在用户端,一个存储于服务端。

  • cookie:存储在用户终端的数据,每次请求浏览器会自动将之前保存的符合规则的cookie携带返还给后端
  • session:存储在服务端的数据

​ cookie 和 session出现后,服务端在用户一次登录完成后,自动生成随机的与该用户绑定的唯一token信息(如 Servlet容器的 JSESSIONID),并写入到cookie中;然后在后续客户端与服务端每次交互中,服务端可以通过客户端携带的token信息反向查找用户信息。

​ 然后随着业务的发展,单体应用越做越大,相应的开发、维护成本也大幅度提升,经常是牵一发而动全身,所以后续将单体应用拆分微服务或独立的子系统也在情理之中;但是拆分后,之前的登录技术也需要有新的技术方案去解决,那么拆分后又碰到了什么问题,导致原先的登录方案无法使用?

基本上分为以下三种情况,不同的情况也对应了不同的解决方案

  1. cookie可共享,session无法共享(现主流单点登录,大部分是解决该种情况的)
    • session 同步 -- 性能存在问题
    • session 共享数据源 -- 分布式session (主流)
  2. cookie无法共享,session共享
  3. cookie无法共享,session也无法共享
    • CAS单点登录

# 单点登录与Shiro集成的代码实现

# 1、引入依赖类

image-20220411091814537

下面依次介绍下5个依赖类的功能作用

# 1.1 SsoProperties

​ 单点登录的配置

按照Spring-boot约定大于配置的思想,该类对大部分的属性都提供了默认值,仅需对少量属性进行配置即可使用

  • enabled :默认情况下,不对单点登录进行启用,如需启用,需手动将该属性置为 true

  • cookie.domain:该属性配置为多个单点登录系统的一级域名,如现有'sso1.xzd.com'、'sso2.xzd.com'、'sso3.xzd.com'三个二级域名,那么cookie.domain 的值需配置为 'xzd.com'

  • aseKey:SSO单点登录时进行AES加密的密钥,有默认值,更改时可另外配置,但需要保证多个单点登录系统的密钥保持一致

  • 其它:可根据实际情况更改默认值, 也可直接使用默认值

package com.lowan.reallinoa.authority.config.shiro;

import lombok.Data;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;

/**
 * @author xuzhendong
 * @date 2022/4/8
 */
@Getter
@Setter
@ConfigurationProperties(prefix = "sso")
public class SsoProperties {


    /**
     * 是否启用SSO单点登录, 默认情况不启用
     */
    private boolean enabled = false;

    /**
     * 未登录code码
     */
    private Integer unauthorizedCode = 401;

    /**
     * 重新授权code码
     */
    private Integer reauthorizedCode = 402;

    /**
     * 授权的接口地址
     */
    private String authUrl = "/auth";

    /**
     * AES加密的key
     */
    private String aseKey = "d7b85f6e214e3f6e";

    /**
     * SSO的cookie模板
     */
    private CookieTemplate cookie;


    @Data
    public static class CookieTemplate {

        private String domain;

        private String name = "sso";

        private boolean secure = false;

        private String path = "/";

        private boolean httpOnly = true;

        private Integer maxAge = -1;
    }
}

# 1.2 SsoToken

单点登录的token令牌

当用户在一个系统中登录后,服务端会向客户端写入token令牌,其它系统通过该令牌实现身份互认,进而实现单点登录

package com.lowan.reallinoa.authority.config.shiro;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.apache.shiro.SecurityUtils;

import java.util.Objects;

/**
 * @author xuzhendong
 * @date 2022/4/8
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SsoToken {

    /**
     * 多个系统间互信的token令牌, 用于获取用户信息
     */
    private String token;

    /**
     * token创建的时间
     */
    private Long createTime;

    /**
     * 创建token时, 与服务端通信的客户端主机信息
     */
    private String host;

    /**
     * 是否为有效token
     *
     * @return true: token有效,否则返回false
     */
    public boolean isValid() {
        // 字段非空校验
        if (token == null || createTime == null) {
            return false;
        }
        return Objects.equals(host, SecurityUtils.getSubject().getSession().getHost());
    }
}

# 1.3 ShiroAesEncryptUtils

安全加解密的工具类

该类仅对外提供了两个公共方法,加密和解密,用于对SSO相关参数进行加密认证,防止敏感信息直接外泄

package com.lowan.reallinoa.authority.config.shiro;

import org.apache.commons.codec.binary.Base64;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Validate;

import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Objects;

/**
 * @author xuzhendong
 * @date 2022/4/8
 */
public class ShiroAesEncryptUtils {

    private static final String ALGORITHMSTR = "AES/ECB/PKCS5Padding";


    /**
     * 解密文本内容
     *
     * @param content 待解密的内容
     * @param aesKey  加密的密钥
     * @return 解密后的文本内容
     * @throws Exception 加密异常
     */
    public static String decrypt(String content, String aesKey) throws Exception {
        if (StringUtils.isBlank(content)) {
            return content;
        }
        byte[] bytes = buildCipher(aesKey, Cipher.DECRYPT_MODE).doFinal(Base64.decodeBase64(content));
        return new String(bytes, StandardCharsets.UTF_8);
    }


    /**
     * 加密文本内容
     *
     * @param content 待加密的内容
     * @param aesKey  加密的密钥
     * @return 加密后的文本内容
     * @throws Exception 加密异常
     */
    public static String encrypt(String content, String aesKey) throws Exception {
        if (StringUtils.isBlank(content)) {
            return content;
        }
        byte[] bytes = buildCipher(aesKey,Cipher.ENCRYPT_MODE)
            .doFinal(content.getBytes(StandardCharsets.UTF_8));
        return Base64.encodeBase64String(bytes);
    }


    /**
     * 构建加密函数
     *
     * @param aesKey 加密的key
     */
    private static Cipher buildCipher(String aesKey, Integer mode) throws Exception {
        KeyGenerator kgen = KeyGenerator.getInstance("AES");
        kgen.init(128);
        Cipher cipher = Cipher.getInstance(ALGORITHMSTR);
        cipher.init(mode, new SecretKeySpec(aesKey.getBytes(), "AES"));
        return cipher;
    }
}

# 1.4 ShiroSsoFilter

​ 实现单点登录的核心类

该类为 Shiro 过滤器的实现类,会对所有请求进行拦截,主要实现两个核心功能

1、单点登录,用户一处登录,其它系统互认互信,免登陆,直接进入系统

2、单点登出,用户一处登出,其它系统也退出登录

对于拦截的请求可能存在一下三种情况

  • 当前系统未登录,其它系统也未登录
    • 通过code码,引导客户端跳转至登录界面,重新登录(手动登录)
  • 当前系统未登录,其它系统已登录
    • 通过code码,引导客户端调用授权接口,获取用户信息(自动登录)
  • 当前系统登录,其它系统未登录
    • 该情况为用户在其它系统已退出登录,若需访问信息,需重新登录(手动登录)
package com.lowan.reallinoa.authority.config.shiro;

import org.apache.commons.lang.Validate;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.web.filter.authc.UserFilter;

import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;

/**
 * @author xuzhendong
 * @date 2022/4/8
 */
public class ShiroSsoFilter extends UserFilter {

    private final SsoProperties ssoProperties;

    public ShiroSsoFilter(SsoProperties ssoProperties) {
        this.ssoProperties = ssoProperties;
        if (ssoProperties.isEnabled()) {
            SsoProperties.CookieTemplate cookie = ssoProperties.getCookie();
            Validate.isTrue(StringUtils.isNotBlank(cookie.getDomain()), "sso.cookie.domain不允许为空");
            Validate.isTrue(StringUtils.isNotBlank(cookie.getName()), "sso.cookie.name不允许为空");
        }
    }

    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        if (!ssoProperties.isEnabled()) {
            return true;
        }
        try {
            SsoToken ssoToken = SsoUtils.getSsoUserFromCookie(request, ssoProperties);
            return getSubject().getPrincipal() != null && ssoToken != null && ssoToken.isValid();
        } catch (Exception e) {
            return false;
        }
    }


    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
        // 跳转授权接口
        if (SsoUtils.getSsoCookie(request, ssoProperties) != null && getSubject().getPrincipal() == null) {
            SsoUtils.writeReauthorizedResponse(response, ssoProperties);
        } else {
            if (getSubject().getPrincipal() != null) {
                getSubject().logout();
            }
            // 跳转登录
            SsoUtils.writeUnauthorizedResponse(response, ssoProperties);
        }
        return false;
    }

    /**
     * 获取访问主体
     */
    private Subject getSubject() {
        return SecurityUtils.getSubject();
    }

}

# 1.5 SsoUtils

单点登录的工具类

package com.lowan.reallinoa.authority.config.shiro;

import com.alibaba.fastjson.JSON;
import org.springframework.http.MediaType;
import org.springframework.lang.Nullable;

import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.PrintWriter;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;

/**
 * @author xuzhendong
 * @date 2022/4/8
 */
public class SsoUtils {

    /**
     * 写入重新授权的响应体内容
     *
     * @param response      响应体
     * @param ssoProperties 单点登录相关的配置信息
     * @throws Exception 消息写入异常
     */
    public static void writeReauthorizedResponse(ServletResponse response, SsoProperties ssoProperties) throws Exception {
        SsoUtils.writeResponse(response, ssoProperties.getReauthorizedCode(), "请重新授权", null);
    }

    /**
     * 写入未登录的响应体内容
     *
     * @param response      响应体
     * @param ssoProperties 单点登录相关的配置信息
     * @throws Exception 消息写入异常
     */
    public static void writeUnauthorizedResponse(ServletResponse response, SsoProperties ssoProperties) throws Exception {
        SsoUtils.writeResponse(response, ssoProperties.getUnauthorizedCode(), "请重新登录", null);
    }


    /**
     * 向响应体写入内容
     *
     * @param response 响应体
     * @param code     响应码
     * @param msg      消息描述
     * @param data     消息内容
     * @throws Exception 消息写入异常
     */
    public static void writeResponse(ServletResponse response, Integer code, String msg, Object data) throws Exception {
        HttpServletResponse resp = (HttpServletResponse) response;
        resp.setContentType(MediaType.APPLICATION_JSON_VALUE);
        resp.setCharacterEncoding(StandardCharsets.UTF_8.name());
        PrintWriter out = resp.getWriter();
        Map<String, Object> paramMap = new HashMap<>(3);
        paramMap.put("code", code);
        paramMap.put("msg", msg);
        if (data != null) {
            paramMap.put("data", data);
        }
        out.println(JSON.toJSONString(paramMap));
        out.flush();
        out.close();
    }


    /**
     * 获取sso单点登录的cookie
     *
     * @param request       请求体
     * @param ssoProperties sso相关的配置参数
     * @return sso单点登录的cookie
     */
    @Nullable
    public static Cookie getSsoCookie(ServletRequest request, SsoProperties ssoProperties) {
        if (ssoProperties.isEnabled()) {
            HttpServletRequest servletRequest = (HttpServletRequest) request;
            Cookie[] cookies = servletRequest.getCookies();
            if (cookies != null) {
                String name = ssoProperties.getCookie().getName();
                for (Cookie cookie : cookies) {
                    if (name.equals(cookie.getName())) {
                        return cookie;
                    }
                }
            }
        }
        return null;
    }

    /**
     * 从 SSO的cookie中获取用户信息
     *
     * @param request       请求体
     * @param ssoProperties SSO参数
     * @return 用户信息
     */
    @Nullable
    public static SsoToken getSsoUserFromCookie(ServletRequest request, SsoProperties ssoProperties) throws Exception {
        SsoToken ssoUser = null;
        Cookie cookie = getSsoCookie(request, ssoProperties);
        if (cookie != null && cookie.getValue() != null) {
            ssoUser = JSON.toJavaObject(JSON
                                        .parseObject(decodeCookieValue(cookie.getValue(), ssoProperties))
                                        , SsoToken.class);
        }
        return ssoUser;
    }

    /**
     * 解码cookie的值
     *
     * @param value 编码后的值
     * @return 解码后的值
     */
    private static String decodeCookieValue(String value, SsoProperties ssoProperties) throws Exception {
        if (value == null) {
            return null;
        }
        return ShiroAesEncryptUtils.decrypt(URLDecoder
                                            .decode(value, StandardCharsets.UTF_8), ssoProperties.getAseKey());
    }

    /**
     * 编码cookie的值
     *
     * @param value 原始值
     * @return 编码后的值
     */
    private static String encodeCookieValue(String value, SsoProperties ssoProperties) throws Exception {
        if (value == null) {
            return null;
        }
        return URLEncoder.encode(ShiroAesEncryptUtils
                                 .encrypt(value, ssoProperties.getAseKey()), StandardCharsets.UTF_8);
    }


    /**
     * 将SSO相关的cookie写入浏览器
     *
     * @param response      响应体
     * @param ssoUser       sso通用的用户体
     * @param ssoProperties sso相关的配置参数
     */
    public static void addSsoCookie(HttpServletResponse response
                                    , SsoToken ssoUser, SsoProperties ssoProperties) throws Exception {
        if (!ssoProperties.isEnabled()) {
            return;
        }
        response.addCookie(buildCookie(ssoProperties, ssoUser));
    }

    /**
     * 删除SSO相关的cookie
     *
     * @param response      响应体
     * @param ssoProperties sso相关的配置参数
     */
    public static void deleteSsoCookie(HttpServletResponse response, SsoProperties ssoProperties) throws Exception {
        if (!ssoProperties.isEnabled()) {
            return;
        }
        Cookie cookie = buildCookie(ssoProperties, null);
        cookie.setMaxAge(0);
        response.addCookie(cookie);
    }


    private static Cookie buildCookie(SsoProperties ssoProperties, Object data) throws Exception {
        SsoProperties.CookieTemplate cookieTemplate = ssoProperties.getCookie();
        Cookie cookie = new Cookie(cookieTemplate.getName(), encodeCookieValue(data == null
                ? null : JSON.toJSONString(data), ssoProperties));
        cookie.setPath(cookieTemplate.getPath());
        cookie.setDomain(cookieTemplate.getDomain());
        cookie.setHttpOnly(cookieTemplate.isHttpOnly());
        cookie.setSecure(cookieTemplate.isSecure());
        cookie.setMaxAge(cookieTemplate.getMaxAge());
        return cookie;
    }
}

# 2、集成shiro

  • 若启用单点登录,将系统间互信互认授权的接口地址加入免认证
  • 将该过滤器在 shiro 中进行定义,并命名为 sso
  • 配置拦截所有路径

image-20220411110304040

# 3、登录相关的代码改造

# 3.1 登录接口

与之前的登录接口相比,原有登录逻辑不变,此处封装doLogin, 然后在登录成功后写入单点登录的token令牌,其它系统通过当前token实现自动认证免登录

这里的token令牌为用户的用户名---多个系统间用户名一致,认定为同一用户

image-20220411111622418

# 3.2 退出登录

同理,用户退出登录后,需要在当前接口删除单点登录的令牌,实现一处登出,其它系统自动登出

image-20220411112411819

# 3.3 授权接口

该接口为单点登录新增接口,实现通过其它系统写入的token令牌,自动登录

image-20220411112654177

# 4、前端登录逻辑的变更

  • 变更前的逻辑:

​ 前端未登录或前端请求接口后端返回未登录的code码,前端重定向至登录接口,引导用户重新登录

  • 变更后的逻辑:

    由于后端已经实现的单点登录,用户在第一次访问当前系统时,可能已经在其它系统登陆过了,可以免登录,所以这里的登录逻辑需要发生变更。

    这里分为两种情况:

    • 前端判断用户未登录(一般为首次进入该系统的前端界面)

      前端先调用后端的 **授权**接口:若后端判定符合免登录的条件,直接返回用户信息,前端保存用户信息然后改为已登录状态,再进行后续的路基操作;

      若判断判定不符合免登录条件,直接返回未登录的code码,前端重定向至登录界面

    • 前端判断用户已登录

      前端按之前的逻辑正常调用接口,通过后端也会通过实际情况返回成功、未登录(登录失效)、重新授权的code码,前端再根据code码执行后续的逻辑判断

# 该方案优缺点

  • 优点

    • 仅需少量代码和配置,改动较小
    • 各系统间无需统一用户数据,只要用户名一致即可
  • 缺点

    • 安全性还有已知的上升空间

      虽然该方案实现了单点登录,但因后端数据源(Redis、MySQL数据库)还是割裂隔离的,所以各系统仅能通过username,做为各系统免登录的token令牌,不具有随机性,容易被拦截仿造。目前的解决方案是,对token进行了加密,并对用户的host进行强验证(即使用过程中,若发现用户的主机信息发生变化,则拒绝服务,强制重新登录),提升了用户仿造token的难度,实现的相对的安全性