单点登录原理及实现

873 阅读9分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

一,背景

单点登录顾名思义就是在多个应用系统中,只需要登录一次,就可以访问其他相互信任的应用系统,免除多次登录的烦恼。比如我们登录了百度账号,再去百度百科,百度文库就不需要再次登录了。

二,原理说明

单点登录主流都是基于共享 cookie 来实现的,下面分别介绍 同域 和 跨域 下两种场景具体怎样实现共享cookie的。

2.1. 同域单点登录

适用场景:都是企业自己的系统,所有系统都使用同一个一级域名通过不同的二级域名来区分。

举个例子:公司有一个一级域名为 xxx.com ,我们有三个系统分别是:门户系统(sso.xxx.com)、应用1(app1.xxx.com)和应用2(app2.xxx.com),需要实现系统之间的单点登录,实现架构如下:

​编辑

核心原理:

  1. 门户系统设置 Cookie 的 domain 为一级域名也就是 xxx.com,这样就可以共享门户的 Cookie 给所有的使用该域名(xxx.xxx.com)的系统

  2. 使用Spring Session等技术让所有系统共享Session

  3. 这样只要门户系统登录之后无论跳转应用1或者应用2,都能通过门户Cookie中的sessionId读取到Session中的登录信息实现单点登录

2.2. 跨域单点登录

单点登录之间的系统域名不一样,例如第三方系统。由于域名不一样不能共享Cookie了,这样就需要通过一个单独的授权服务(UAA)来做统一登录,并基于共享UAA的Cookie来实现单点登录。

举个例子:有两个系统分别是:应用1(webApp.com)和应用2(xxx.com)需要实现单点登录,另外有一个授权中心(sso.com),实现架构如下:

​编辑

核心原理:

  1. 访问系统1判断未登录,则跳转到UAA系统请求授权

  2. 在系统域名sso.com下的登录地址中输入用户名/密码完成登录

  3. 登录成功后UAA系统把登录信息保存到Session中,并在浏览器写入域为sso.com的Cookie

  4. 访问系统2判断未登录,则跳转到UAA系统请求授权

  5. 由于是跳转到UAA系统的域名sso.com下,所以能通过浏览器中UAA的Cookie读取到Session中之前的登录信息完成单点登录

三,技术实现

3.1,基于Spring Security实现(前后端不分离)

​编辑

Oauth2单点登录除了需要授权中心完成统一登录/授权逻辑之外

各个系统本身(sso客户端)也需要实现以下逻辑:

  1. 拦截请求判断登录状态

  2. 授权中心通过Oauth2授权码模式交互完成登录/单点登录

  3. 保存用户登录信息

以上逻辑只需使用一个 @EnableOAuth2Sso 注解即可实现

@EnableOAuth2Sso
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Value("${security.oauth2.sso.login-path:}")
    private String loginPath;

    @Resource
    private LogoutSuccessHandler ssoLogoutSuccessHandler;

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests().anyRequest().authenticated()
                .and()
                .csrf().disable()
                .logout()
                .logoutSuccessHandler(ssoLogoutSuccessHandler);
        if (StrUtil.isNotEmpty(loginPath)) {
            http.formLogin().loginProcessingUrl(loginPath);
        }
    }
}

@Component
public class SsoLogoutSuccessHandler implements LogoutSuccessHandler {

	@Value("${zlt.logout-uri:''}")
	private String logoutUri;

	private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

	@Override
	public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
		OAuth2Authentication oauth2Authentication = (OAuth2Authentication)authentication;
		OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails)oauth2Authentication.getDetails();
		String accessToken = details.getTokenValue();
		redirectStrategy.sendRedirect(request, response, logoutUri+accessToken);
	}
}

zlt:
  api-uaa:
    url: http://127.0.0.1:9900/api-uaa/oauth
  logout-uri: ${zlt.api-uaa.url}/remove/token?redirect_uri=http://127.0.0.1:8080&access_token=

security:
  oauth2:
    sso:
      login-path: /singleLogin
    client:
      client-id: zlt
      client-secret: zlt
      access-token-uri: ${zlt.api-uaa.url}/token
      user-authorization-uri: ${zlt.api-uaa.url}/authorize
    resource:
      token-info-uri: ${zlt.api-uaa.url}/check_token

3.2,基于Spring Security实现(前后端分离)

跨域间的前后端分离项目也是基于共享统一授权服务的cookie来实现单点登录的,但是与非前后分离不一样的是存在以下问题需要解决

  1. 没有过滤器/拦截器,需要在前端判断登录状态

  2. 需要自己实现oauth2的授权码模式交互逻辑

  3. 需要解决安全性问题,oauth2的clientSecret参数放在前端不安全

下面是前后端分离项目的三个角色(前端WEB工程、后端API工程、授权中心)间进行登录/单点登录时的交互逻辑架构图

​编辑

前端WEB工程有几个点需要注意:

  1. 红色线条为重定向跳转

  2. 前端工程可通过是否存在 access_token 判断登录状态

  3. 前端工程跳转UAA之前需记录用户访问的页面地址,方便登录完成后重定向回去

PS:为什么获取access_token需要请求后端API工程去完成,而不是前端WEB工程自己直接请求UAA呢?因为安全性问题!这一步需要传clientSecret参数,而通过后台来配置这个参数就不需要暴露给前端了。

@Slf4j
@RestController
public class ApiController {
    @Value("${zlt.sso.client-id:}")
    private String clientId;

    @Value("${zlt.sso.client-secret:}")
    private String clientSecret;

    @Value("${zlt.sso.redirect-uri:}")
    private String redirectUri;

    @Value("${zlt.sso.access-token-uri:}")
    private String accessTokenUri;

    @Value("${zlt.sso.user-info-uri:}")
    private String userInfoUri;

    private final static Map<String, Map<String, Object>> localTokenMap = new HashMap<>();

    @GetMapping("/token/{code}")
    public String tokenInfo(@PathVariable String code) throws UnsupportedEncodingException {
        //获取token
        Map tokenMap = getAccessToken(code);
        String accessToken = (String) tokenMap.get("access_token");
        //获取用户信息
        Map userMap = getUserInfo(accessToken);
        List<String> roles = getRoles(userMap);

        Map result = new HashMap(2);
        String username = (String) userMap.get("username");
        result.put("username", username);
        result.put("roles", roles);
        localTokenMap.put(accessToken, result);

        return accessToken;
    }

    /**
     * 获取token
     */
    public Map getAccessToken(String code) throws UnsupportedEncodingException {
        RestTemplate restTemplate = new RestTemplate();
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        byte[] authorization = (clientId + ":" + clientSecret).getBytes("UTF-8");
        BASE64Encoder encoder = new BASE64Encoder();
        String base64Auth = encoder.encode(authorization);
        headers.add("Authorization", "Basic " + base64Auth);

        MultiValueMap<String, String> param = new LinkedMultiValueMap<>();
        param.add("code", code);
        param.add("grant_type", "authorization_code");
        param.add("redirect_uri", redirectUri);
        param.add("scope", "app");
        HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(param, headers);
        ResponseEntity<Map> response = restTemplate.postForEntity(accessTokenUri, request, Map.class);
        Map result = response.getBody();
        return result;
    }

    /**
     * 获取用户信息
     */
    public Map getUserInfo(String accessToken) {
        RestTemplate restTemplate = new RestTemplate();
        Map result = restTemplate.getForObject(userInfoUri + "?access_token=" + accessToken, Map.class);
        return (Map) result.get("datas");
    }

    private List<String> getRoles(Map userMap) {
        List<Map<String, String>> roles = (List<Map<String, String>>) userMap.get("roles");
        List<String> result = new ArrayList<>();
        if (CollectionUtil.isNotEmpty(roles)) {
            roles.forEach(e -> {
                result.add(e.get("code"));
            });
        }
        return result;
    }

    @GetMapping("/user")
    public Map<String, Object> user(HttpServletRequest request) {
        String token = request.getParameter("access_token");
        return localTokenMap.get(token);
    }

    @GetMapping("/logoutNotify")
    public void logoutNotify(HttpServletRequest request) {
        String tokens = request.getParameter("tokens");
        log.info("=====logoutNotify: " + tokens);
        if (StrUtil.isNotEmpty(tokens)) {
            for (String accessToken : tokens.split(",")) {
                localTokenMap.remove(accessToken);
            }
        }
    }
}

3.3,基于Security实现OIDC单点登录

OIDC 是 OpenID Connect 的简称,OIDC=(Identity, Authentication) + OAuth 2.0。它在 OAuth2 上构建了一个身份层,是一个基于 OAuth2 协议的身份认证标准协议。我们都知道 OAuth2 是一个授权协议,它无法提供完善的身份认证功能,OIDC 使用 OAuth2 的授权服务器来为第三方客户端提供用户的身份认证,并把对应的身份认证信息传递给客户端,且完全兼容 OAuth2。

OAuth2 提供了 Access Token 来解决授权第三方 客户端 访问受保护资源的问题;OIDC 在这个基础上提供了 ID Token 来解决第三方客户端标识用户身份认证的问题。OIDC 的核心在于 OAuth2 的授权流程中,一并提供用户的身份认证信息 ID Token 给到第三方 客户端ID Token 使用 JWT 格式来包装。

OIDC协议授权返回示例:

{
    "resp_code": 200,
    "resp_msg": "ok",
    "datas": {
        "access_token": "d1186597-aeb4-4214-b176-08ec09b1f1ed",
        "token_type": "bearer",
        "refresh_token": "37fd65d8-f017-4b5a-9975-22b3067fb30b",
        "expires_in": 3599,
        "id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwOi8vemx0MjAwMC5jbiIsImlhdCI6MTYyMTY5NjU4MjYxNSwiZXhwIjoxNjIxNjk2NjQyNjE1LCJzdWIiOiIxIiwibmFtZSI6IueuoeeQhuWRmCIsImxvZ2luX25hbWUiOiJhZG1pbiIsInBpY3R1cmUiOiJodHRwOi8vcGtxdG1uMHAxLmJrdC5jbG91ZGRuLmNvbS_lpLTlg48ucG5nIiwiYXVkIjoiYXBwIiwibm9uY2UiOiJ0NDlicGcifQ.UhsJpHYMWRmny45K0CygXeaASFawqtP2-zgWPDnn0XiBJ6yeiNo5QAwerjf9NFP1YBxuobRUzzhkzRikWGwzramNG9na0NPi4yUQjPNZitX1JzlIA8XSq4LNsuPKO7hS1ALqqiAEHS3oUqKAsjuE-ygt0fN9iVj2LyL3-GFpql0UAFIHhew_J7yIpR14snSh3iLVTmSWNknGu2boDvyO5LWonnUjkNB3XSGD0ukI3UEEFXBJWyOD9rPqfTDOy0sTG_-9wjDEV0WbtJf4FyfO3hPu--bwtM_U0kxRbfLnOujFXyVUStiCKG45wg7iI4Du2lamPJoJCplwjHKWdPc6Zw"
    }
}

可以看到与普通的 OAuth2 相比返回的信息中除了有 access_token 之外还多出了 id_token 属性。

ID Token 是一个安全令牌,由授权服务器提供的包含用户信息的 JWT 格式的数据结构,得益于 JWT(JSON Web Token)的自包含性,紧凑性以及防篡改机制,使得 ID Token 可以安全的传递给第三方客户端程序并且容易被验证。

id_token包含以下内容

{
  "iss": "http://zlt2000.cn",
  "iat": 1621696582615,
  "exp": 1621696642615,
  "sub": "1",
  "name": "管理员",
  "login_name": "admin",
  "picture": "http://xxx/头像.png",
  "aud": "app",
  "nonce": "t49bpg"
}

「iss」:令牌颁发者

「iat」:令牌颁发时间戳

「exp」:令牌过期时间戳

「sub」:用户id

「name」:用户姓名

「login_name」:用户登录名

「picture」:用户头像

「aud」:令牌接收者,OAuth应用ID

「nonce」:随机字符串,用来防止重放攻击

与 JWT 的 Access Token 区别

是否可以直接使用 JWT 方式的 Access Token 并在 Payload 中加入用户信息来代替 ID Token 呢?

虽然在 Access Token 中可以加入用户的信息,并且是防篡改的,但是用户的每次请求都需要携带着 Access Token,这样不但增加了带宽,而且很容易泄露用户的信息。

与 UserInfo 端点的区别

通常 OIDC 协议都需要另外提供了一个 Get /userinfo 的 Endpoint,需要通过 Access Token 调用该 Endpoint 来获取详细的用户信息,这个方法和 ID Token 同样都可以获取用户信息,那两者有什么区别呢?

相比较于 Get /userinfo 的接口使用 ID Token 可以减少远程 API 调用的额外开销;使用那个主要是看 「需求」,当你只需要获取用户的基本信息直接使用 ID Token 就可以了,并不需要每次都通过 Access Token 去调用 Get /userinfo 获取详细的用户信息。

OIDC 单点登录流程

​编辑

大部分的流程与 OAuth2 的授权码模式相同这里就不多讲述了,其中下面两个步骤需要说明一下:

  • 解析 ID Token 的公钥可以是预先提供给第三方系统也可以是提供接口获取。

  • 「自动注册用户」 指的是第一次单点登录的时候,由于用户信息不存在需要在本系统中生成该用户数据;例如你从未在 CSDN 中注册也可以使用微信来登录该网站。

实现

先说一下扩展最终的目标是需要达到以下效果:

  • 授权码模式:/oauth/authorize?client_id={client_id}&redirect_uri={redirect_uri}&response_type=code

  • OIDC 模式:/oauth/authorize?client_id={client_id}&redirect_uri={redirect_uri}&response_type=code id_token

目标是要通过在 response_type 中的传值来控制是否使用 OIDC 模式,如果使用则在 response_type 中增加 id_token 的值。

由于需要在 OAuth2 返回的内容中添加 ID Token 属性,所以实现这个扩展的关键就是需要通过 Security 的 TokenEnhancer 来为 Token 添加自定义字段;

定义 TokenEnhancer 的 Bean 来扩展 Token:

 @Bean
    @Order(1)
    public TokenEnhancer tokenEnhancer(@Autowired(required = false) KeyProperties keyProperties
                , IClientService clientService
                , TokenStoreProperties tokenStoreProperties) {
        return (accessToken, authentication) -> {
            Set<String> responseTypes = authentication.getOAuth2Request().getResponseTypes();
            Map<String, Object> additionalInfo = new HashMap<>(3);
            String accountType = AuthUtils.getAccountType(authentication.getUserAuthentication());
            if (StrUtil.isNotEmpty(accountType)) {
                additionalInfo.put(SecurityConstants.ACCOUNT_TYPE_PARAM_NAME, accountType);
            }

            if (responseTypes.contains(SecurityConstants.ID_TOKEN)
                    || "authJwt".equals(tokenStoreProperties.getType())) {
                Object principal = authentication.getPrincipal();
                //增加id参数
                if (principal instanceof SysUser) {
                    SysUser user = (SysUser)principal;
                    if (responseTypes.contains(SecurityConstants.ID_TOKEN)) {
                        //生成id_token
                        setIdToken(additionalInfo, authentication, keyProperties, clientService, user);
                    }
                    if ("authJwt".equals(tokenStoreProperties.getType())) {
                        additionalInfo.put("id", user.getId());
                    }
                }
            }
            ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo);
            return accessToken;
        };
    }

通过授权的 response_type 参数来判断是否需要生成 id_token。

 /**
     * 生成id_token
     * @param additionalInfo 存储token附加信息对象
     * @param authentication 授权对象
     * @param keyProperties 密钥
     * @param clientService 应用service
     */
    private void setIdToken(Map<String, Object> additionalInfo, OAuth2Authentication authentication
            , KeyProperties keyProperties, IClientService clientService, SysUser user) {
        String clientId = authentication.getOAuth2Request().getClientId();
        Client client = clientService.loadClientByClientId(clientId);
        if (client.getSupportIdToken()) {
            String nonce = authentication.getOAuth2Request().getRequestParameters().get(IdTokenClaimNames.NONCE);
            long now = System.currentTimeMillis();
            long expiresAt = System.currentTimeMillis() + client.getIdTokenValiditySeconds() * 1000;
            String idToken = OidcIdTokenBuilder.builder(keyProperties)
                    .issuer(SecurityConstants.ISS)
                    .issuedAt(now)
                    .expiresAt(expiresAt)
                    .subject(String.valueOf(user.getId()))
                    .name(user.getNickname())
                    .loginName(user.getUsername())
                    .picture(user.getHeadImgUrl())
                    .audience(clientId)
                    .nonce(nonce)
                    .build();

            additionalInfo.put(SecurityConstants.ID_TOKEN, idToken);
        }
    }