超超不开心。

V1

2022/03/08阅读:73主题:默认主题

带你了解SSO登录全过程

一天一个小知识(工程篇):带你了解单点登录(SSO)全过程

不知不觉,已经从学生转为社🐶已经八个月了。这八个月忙于公司里的工作,公众号停更了好久。 最近在组里参与一个技术项目,其中一个需求就是本文要介绍到的SSO登录,接下来我们一起来了解。

SSO的概述

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

SSO实现机制

要实现SSO,需要以下主要的功能:

  • 所有应用系统共享一个身份认证系统。统一认证系统是SSO的前提之一。认证系统的主要功能是将用户的登录信息和用户信息库相比较,对用户进行登录认证;认证成功后,认证系统应该生成统一的认证标志(ticket),返还给用户。另外,认证系统还应该对ticket进行效验,判断其有效性。

  • 所有应用系统(业务系统)能够识别和提取ticket信息。要实现SSO的功能,让用户只登录一次,就必须让应用系统能够识别已经登录过的用户。应用系统(业务系统)应该能对ticket进行识别和提取,通过与认证系统的通讯,能自动判断当前用户是否登录过,从而完成单点登录的功能。

如上图所示,图中有4个系统,分别是Application1、Application2、Application3、和SSO。其中,Application1、Application2、Application3没有登录模块,而SSO只有登录模块,没有其他的业务模块。而当Application1、Application2、Application3需要登录时,将统一跳转到SSO系统。随着SSO系统完成登录,其他的应用系统也就随之登录了。这样就符合单点登录(SSO)的定义。

  • 实际例子 百度地图和百度贴吧都是百度的产品,当用户登录过百度贴吧后,再打开百度地图,系统便自动帮用户登录百度地图,这就属于单点登录。

具体实现方案

普通的登录认证过程

如上图所示,我们在浏览器(Browser)中访问一个业务系统,这个业务系统需要登录,我们填写完用户名和密码后,完成登录认证。这时,我们在这个用户的session中标记登录状态为yes(已登录),同时在浏览器(Browser)中写入Cookie,这个Cookie是这个用户的唯一标识。下次我们再访问这个应用的时候,请求中会带上这个Cookie,服务端会根据这个Cookie找到对应的session,通过session来判断这个用户是否登录。如果不做特殊配置,这个Cookie的名字叫做JSESSIONID,值在服务端(server)是唯一的。

同域名下的单点登录

cookiedomin属性设置为当前域的父域,并且父域的cookie会被子域所共享。path属性默认为web应用的上下文路径。

利用 Cookie 的这个特点,我们只需要将 Cookie 的 domain属性设置为父域的域名(主域名),同时将 Cookie 的 path 属性设置为根路径,将Token保存到父域中。这样所有的子域应用就都可以访问到这个 Cookie。

具体来看:现在我们需要一个登录系统,叫做sso.machao.com。我们只要在sso.machao.com登录,这样tieba.machao.commap.machao.com也就登录了。通过上面的SSO实现机制,我们能发现,在sso.machao.com中登录了,其实是在sso.machao.com的服务端的session中记录了登录状态,同时在浏览器端(Browser)的sso.machao.com下写入了Cookie。那么我们怎么才能让tieba.machao.commap.machao.com登录呢?这里有两个问题:

  • Cookie是不能跨域的,我们Cookie的domain属性是sso.machao.com,在给tieba.machao.commap.machao.com发送请求是带不上的。

  • sso、tieba和map是不同的应用,它们的session存在自己的应用内,是不共享的。

那么我们如何解决这两个问题呢?

  • 针对第一个问题,我们先来了解一下cookie的一些性质.cookiedomin属性设置为当前域的父域,并且父域的cookie会被子域所共享。path属性默认为web应用的上下文路径。利用 Cookie 的这个特点,我们只需要将 Cookie 的 domain属性设置为父域的域名(主域名),同时将 Cookie 的 path 属性设置为根路径,将Token保存到父域中。这样所有的子域应用就都可以访问到这个Cookie。不过这要求应用系统的域名需建立在一个共同的主域名之下,如 tieba.machao.commap.machao.com,它们都建立在 machao.com 这个主域名之下,那么它们就可以通过这种方式来实现单点登录。

  • 针对第二个的session问题。当我们在sso系统登录了,这时再访问tieba,Cookie也带到了tieba的服务端(Server),tieba的服务端怎么找到这个Cookie对应的Session呢?这里就要把3个系统的Session共享,如图所示。共享Session的解决方案有很多,例如:Spring-Session(可参考链接:https://www.cnblogs.com/softidea/p/10323281.html)。这样第2个问题也解决了。

流程可参考下图:

不同域名下的单点登录

目前比较常见的还是不同顶域下的多业务系统如何实现sso登录,因为不同顶域之间的Cookie是无法共享的,这时候就无法用到上面提到的同顶域下的单点登录了。那接下来我们就具体来看一下CAS标准的单点登录流程。 引用一下别的大佬画的图(参考链接:https://blog.csdn.net/qq_21251983/article/details/52695206):

上图是CAS的标准单点登录流程,也是企业中最常用到的sso登录标准规范,具体流程如下:

(1)用户访问app1系统,app1系统是需要登录的,但用户现在没有登录。

(2)跳转到CAS server,即SSO登录系统,图中的CAS Server就是SSO系统。SSO系统也没有登录态(浏览器端没有cookie,服务器端没有session),弹出用户登录页。

(3)用户填写用户名、密码,SSO系统进行认证后,将登录状态写入SSO服务端的session,浏览器中写入SSO域下的Cookie。

(4)SSO系统登录完成后会生成一个ST(Service Ticket),然后跳转到app1系统,同时将ST作为参数传递给app系统。

(5)app1系统拿到ST后,从后台向SSO发送请求,验证ST是否有效。

(6)验证通过后,app1系统将登录状态写入session并设置app1域下的Cookie。

至此,跨域单点登录就完成了。以后我们再访问app1系统时,app1就是登录的。

接下来,我们再看看访问app2系统时的流程。

(1)用户访问app2系统,app2系统没有登录,跳转到SSO。

(2)由于此时SSO已经登录了,不需要重新登录认证。

(3)SSO生成ST,浏览器跳转到app2系统,并将ST作为参数传递给app2。

(4)app2拿到ST,后台访问SSO,验证ST是否有效。

(5)验证成功后,app2将登录状态写入session,并在app2域下写入Cookie。

这样,app2系统不需要走登录流程,就已经是登录了。SSO,app和app2在不同的域,它们之间的session不共享也是没问题的。

另外:

用户登录成功之后,会与sso认证中心及各个子系统建立会话,用户与sso认证中心建立的会话称为全局会话。

用户与各个子系统建立的会话称为局部会话,局部会话建立之后,用户访问子系统受保护资源将不再通过sso认证中心。

全局会话与局部会话有如下约束关系:

- 局部会话存在,全局会话一定存在
- 全局会话存在,局部会话不一定存在
- 全局会话销毁,局部会话必须销毁

前后端代码实现

前端代码
// axios.js
const CAS = 'https://sso.machao.com/cas/login';
const CALLBACK = window.location.origin + '/rest/sso/callback';

const AUTO_GOTO_CAS_SKIP_URL = ['/'];   // 有的首页需要自动sso登录的话,可以将这个注释掉

axios.interceptors.request.use(config => {
    config.headers.Accept = 'application/json';
    config.withCredentials = true;
    config.baseURL = API_URL;
    return config;
});

axios.interceptors.response.use(
    res => {
        const { data } = res;
        if (data && data.code === '200') {
            return data;
        }
        if (data && data.code !== '200' && data.message) {
            message.error(data.message);
        }
        return Promise.reject(data);
    },
    // 前端和后端约定好无登录态返回的状态码401
    err => {
        if (err?.response?.status === 401 && !AUTO_GOTO_CAS_SKIP_URL.includes(window.location.pathname)) {
            // 跳转sso登录页面,service里是业务系统对应的登录鉴权接口,拿到cas登录后的ticket后用callback接口进行权限认证,鉴权通过,会在业务域名下种业务系统局部登录态的cookie
            // 认证通过,就重定向到浏览器url输入的原页面
            window.location.href = `${CAS}?service=${encodeURIComponent(
                CALLBACK + `?redirect=${encodeURIComponent(window.location.href)}`
            )}
`
;
        }
        Promise.reject(err);
    }
);
后端代码
    // controller层
    @ResponseBody
    @RequestMapping(value = "/callback")
    public void callback(HttpServletRequest request, HttpServletResponse response) throws IOException {
        String userName = getUserName(request);
        if (StringUtils.isNotBlank(userName)) {
            String userToken = tokenService.getTokenByUserName(userName);
            log.info("userName: {}, userToken:{}", userName, userToken);
            Cookie cookieToken = new Cookie("userToken", userToken);
            cookieToken.setMaxAge(ApplicationConstants.COOKIE_AGE_SECONDS);
            cookieToken.setPath("/");
            cookieToken.setHttpOnly(true);
            response.addCookie(cookieToken);
            Cookie cookieName = new Cookie("userName", userName);
            cookieName.setMaxAge(ApplicationConstants.COOKIE_AGE_SECONDS);
            cookieName.setPath("/");
            cookieName.setHttpOnly(true);
            response.addCookie(cookieName);

            String redirect = CommonUtils.safeGetParameter(request, "redirect");
            SecurityUtils.checkResponseSplitting(redirect);
            response.sendRedirect(redirect);
        }
    }
    
        private String getUserName(HttpServletRequest request) {
        String ticket = CommonUtils.safeGetParameter(request, "ticket");
        if (CommonUtils.isNotBlank(ticket)) {
            try {
                Assertion assertion = validator.validate(ticket, constructServiceUrl(request));
                return assertion.getPrincipal().getName();
            } catch (Exception e) {
                log.error("failed to validate sso", e);
            }
        }
        log.warn("sso callback, ticket is blank");
        return null;
    }

    private String constructServiceUrl(HttpServletRequest request) throws Exception {
        String uri = request.getRequestURI();
        String redirect = request.getParameter("redirect");
        if (StringUtils.isNotBlank(redirect)) {
            uri = uri + "?redirect=" + URLEncoder.encode(redirect, "UTF-8");
        }
        return "https://" + request.getServerName() + uri;
    }
/**
 * Spring MVC 配置
 *
 */

@Configuration
class SpringMvcConfig {
    private static final int TOKEN_FILTER_ORDER = xxxx;

    @Bean
    WebServerCustomizer applicationWebServerCustomizer() {
        return new WebServerCustomizer();
    }

    @Bean
    WebMvcConfigurer webMvcConfigurer() {
        return new WebMvcConfigurer() {
            @Override
            public void addFormatters(@Nonnull FormatterRegistry registry) {
                // 用于 string <==> HasIntValue 之间的转换
                HasIntValueConverter converter = new HasIntValueConverter();
                registry.addConverter(converter);
            }
        };
    }

    @Bean
    public TokenFilter tokenFilter() {
        return new TokenFilter();
    }

    @Bean
    public FilterRegistrationBean<TokenFilter> filterRegistrationBeanTokenFilter(TokenFilter tokenFilter) {
        FilterRegistrationBean<TokenFilter> registrationBean = new FilterRegistrationBean<>();
        registrationBean.setFilter(tokenFilter);
        registrationBean.setOrder(TOKEN_FILTER_ORDER);
        return registrationBean;
    }
}


import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.LinkedList;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Pattern;

import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.apache.commons.codec.binary.Base64;
import org.springframework.http.HttpStatus;

import com.google.common.collect.Sets;

import lombok.extern.slf4j.Slf4j;


@Slf4j
public class TokenFilter implements Filter {
    private static final String KEY = "xxxx";
    private static final String ROOT_PATH = "/";

    private PatternFilter allowList = new PatternFilter(Sets.newHashSet("/rest/sso/callback"));

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException 
{
        log.warn("enter doFilter");
        //本地环境不校验
        if (ApplicationEnv.current() == EnvironmentEnum.DEV) {
            log.warn("enter dev");
            chain.doFilter(request, response);
            return;
        }

        //白名单path不校验
        String path = ((HttpServletRequest) request).getServletPath();
        if (ROOT_PATH.equals(path) || this.allowList.contains(path)) {
            log.warn("enter white path");
            chain.doFilter(request, response);
            return;
        }

        Cookie[] cookies = ((HttpServletRequest) request).getCookies();
        log.info("cookies:");
        if (cookies == null) {
            log.warn("TokenFilter.doFilter: cookies is null");
            ((HttpServletResponse) response).setStatus(HttpStatus.UNAUTHORIZED.value());
            return;
        }

        String userNameByToken = null;
        String userName = null;
        for (Cookie cookie : cookies) {
            log.info("进入cookie循环");
            if (Objects.equals("userToken", cookie.getName())) {
                userNameByToken = getUserNameByToken(cookie.getValue());
            } else if (Objects.equals("userName", cookie.getName())) {
                userName = cookie.getValue();
            }
        }

        if (userNameByToken != null && Objects.equals(userNameByToken, userName)) {
            log.info("TokenFilter success for {}", userName);
            chain.doFilter(request, response);
            return;
        }

        //鉴权失败,给客户端返回401
        log.warn("TokenFilter.doFilter: UNAUTHORIZED");
        ((HttpServletResponse) response).setStatus(HttpStatus.UNAUTHORIZED.value());
    }

    @Override
    public void destroy() {
        log.info("destroy");
    }

    private static String getUserNameByToken(String token) {
        int index = token.indexOf("-");
        if (index != -1) {
            byte[] bytes = Base64.decodeBase64(token.substring(0, index));
            Key keySpec = new SecretKeySpec(KEY.getBytes(StandardCharsets.UTF_8), "AES");
            Cipher cipher;

            try {
                cipher = Cipher.getInstance("AES");
                cipher.init(2, keySpec);
                String userName = new String(cipher.doFinal(bytes), StandardCharsets.UTF_8);
                if (token.substring(index + 1).equals(userName)) {
                    return userName;
                }
            } catch (Exception var6) {
                log.error("aes decrypt error. token: {}", token, var6);
            }
        }

        return null;
    }

    public static class PatternFilter {
        private final List<Pattern> patternList = new LinkedList<>();

        public PatternFilter(Set<String> patternStrList) {
            if (Objects.isNull(patternStrList) || patternStrList.isEmpty()) {
                return;
            }
            init(patternStrList);
        }

        private void init(Set<String> patternStrList) {
            for (String regex : patternStrList) {
                Pattern pattern = Pattern.compile(regex);
                this.patternList.add(pattern);
            }
        }

        public boolean contains(String path) {
            for (Pattern p : this.patternList) {
                if (p.matcher(path).find()) {
                    return true;
                }
            }
            return false;
        }
    }
}

分类:

后端

标签:

后端

作者介绍

超超不开心。
V1