SSO 轻量级实现指南(原生 Java 实现):SSO Client 部分

本文详细介绍了SSO(单点登录)客户端如何接入认证中心,包括用户注册、登录、注销等操作,并探讨了客户端在接入SSO时需要建立本地用户登录状态的问题。通过自建用户登录会话,客户端在用户登录后创建Session,后续通过拦截器在本地校验AccessToken的有效性,以提高效率。同时,文章提到了对已验证Token的缓存和自描述Token等优化方案。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

根据单点登录的定义,客户端可以完全不用创建自己的用户系统,它只需要接入 SSO 中心的服务就好。SSO 中心关于用户的常规业务都在其内。那么客户端接入单点登录,需要做什么工作呢?首先用户一般常规操作有:

  • 用户注册。这部分 SSO 中心提供注册接口。客户端自定义自己风格注册 UI,跨域请求数据到 SSO 中心接口即可;
  • 用户登录。这部分 SSO 中心提供登录接口。客户端自定义自己风格登录 UI,跨域请求数据到 SSO 中心接口即可;
  • 用户注销登陆。这部分 SSO 中心提供登录接口,跨域请求数据到 SSO 中心接口即可;
  • 用户常规查询操作,例如查询列表、单个用户详情等,这部分 SSO 中心开放相关 API。

一般常规接口上文已经讨论过了。可见 SSO 中心一个特性要求便是允许“跨域访问”,这个问题不大,进行相关配置即可。

SSO 中心,即认证中心,关键一点在于用户的认证。除了上述登录是重要的认证过程外,每次涉及相关操作都必须进行认证,否则就是非法访问。

认证的问题

如果按照 OAuth 本来的目的,资源服务器跟认证服务器是在一块的,比如说微博,它有个开放平台你可以根据 AccessToken 获取它微博内容。每次访问都有提供 AccessToken 参数,看是否合法才允许访问。

但目前我们搞的不是纯粹 OAuth,上文《SSO 与 OAuth 傻傻分不清?》小节已经说过了。SSO 认证中心往往不是跟资源服务器在一起的“单体”结构,而是独立部署的;而且应用端(即客户端)肯定都有自己的资源服务,肯定需要用户认证、权限校验之类的操作。那么问题来了,校验客户端凭证令牌(即 AccessToken)这项工作,——是放在应用端还是 SSO 中心呢?

显然易见,作为统一的认证中心,SSO 中心无疑拥有最根本的用户状态记录,一切皆以 SSO 中心的为准。但每次访问资源的认证工作都要通讯 SSO 中心,性能成本会不会太高呢?对于 SSO 中心服务器的性能也是严重的考验。对此,笔者考虑了以下几个个解决方案。

  • 还是在 SSO 中心校验,但采取优化手段:对已验证的 token 进行缓存,仅首次访问时调用 SSO 验证一次,一般缓存10分钟这种,便于 SSO 进行 token 撤销。
  • 无须 SSO 校验 token,采用自描述的 token。这种自描述的 Token 比普通的 Token 的复杂,解密之后包含了更多的信息,根据这些信息对比、校验便能清楚是否合法,以及一定的用户信息。举个例子,如“重置密码”,在邮件中包含一个带 token 的连接,后端得到这 token 后其实有时间戳的信息的,再对比一下便能知道是否超时的请求。
  • 采用自描述的 Token,其实跟大家说 JWT 就可以了,它就是干这事的。不过笔者说实话还不太懂 JWT,当前方案中还没有使用 JWT。
  • 应用端自建用户登录会话。其实就是冗余一套 SSO 中心的,用户登录之后回来马上搞自己的 Session。但怎么同步是个问题,而且隐约好像不是“单点”的意思了。当前我正在使用这方案。

应用端自建用户登录会话

既然选定了这个方案,那我们就看看怎么做吧。首先是用户登录之后马上建立 Session。源码在这里

这属于客户端登录的一部分,得到授权码之后在服务端发起请求。

@GetMapping(value = "clientLogin", produces = JSON)
public String clientLogin(@RequestParam String code, HttpServletRequest req) {
	Map<String, Object> params = new HashMap<>();
	params.put("code", code);
	params.put("grant_type", GRANT_TYPE);
	params.put("client_id", clientId);
	params.put("client_secret", clientSecret);

	Map<String, Object> result = Post.api(api + "/sso/authorize", params);

	UserSession saveSession = saveSession(result);
	// 存入 session
	req.getSession().setAttribute(saveSession.accessToken.getAccessToken(), saveSession);

	return "${User.home}".equals(userHome) ? toJson(result) : "redirect:/" + userHome;
}

/**
 * JSON 结果转换为 Session 存储,形成本地登录状态
 * 
 * @param result
 * @return
 */
static UserSession saveSession(Map<String, Object> result) {
	AccessToken accessToken = new AccessToken();
	accessToken.setAccessToken(result.get("access_token").toString());
	accessToken.setRefreshToken(result.get("refresh_token").toString());
	accessToken.setScope(result.get("scope").toString());
	accessToken.setExpiresIn(((Integer) result.get("expires_in")).longValue());

	@SuppressWarnings("unchecked")
	Map<String, Object> userJson = (Map<String, Object>) result.get("user");
	User user = MapTool.map2Bean(userJson, User.class, true);

	UserSession userSession = new UserSession();
	userSession.accessToken = accessToken;
	userSession.user = user;

	return userSession;
}

若登录成功,就在客户端本地产生 Session。其中重点就是 UserSession ,它包含了用户和 AccessToken 两种对象,以 Token 为 key 存到 Session 中。

校验拦截 Token

有了本地的用户登录状态,就无须访问 SSO 中心校验了,于是也变得简单和高效了。所有校验都发生在本地进行。我们看看这个拦截器 SsoAccessTokenInterceptor,它是标准的 Spring 拦截器。

你先需要在 yaml 配置中定义一下要保护资源的访问路径,即接口,按照 Spring 拦截器的配置。

User:
 resources: /api/**, /user/**	  # 要保护的资源
 excludeResources: /user/login/** # 排除的路径

记得路径后面要加上 ** 同贝所有子路径。

/**
 * 要保护的资源(只有登录了才能访问)
 */
@Value("${User.resources}")
private String[] protectPerfix;

/**
 * 要保护的资源(只有登录了才能访问)
 */
@Value("${User.excludeResources}")
private String[] excludeResources;

/**
 * 加入拦截器
 */
@Override
public void addInterceptors(InterceptorRegistry registry) {
	registry.addInterceptor(tokenInterceptor).addPathPatterns(protectPerfix).excludePathPatterns(excludeResources);

	super.addInterceptors(registry);
}

拦截器代码

import java.io.IOException;
import java.time.LocalDateTime;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;

import com.ajaxjs.framework.BaseController;
import com.ajaxjs.user.sso.model.AccessToken;
import com.ajaxjs.user.sso.model.UserSession;
import com.ajaxjs.util.date.LocalDateUtils;

/**
 * 校验 AccessToken 的拦截器
 * 
 * @author Frank Cheung<sp42@qq.com>
 *
 */
@Component
public class SsoAccessTokenInterceptor implements HandlerInterceptor {
	@Override
	public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) {
		String accessToken = req.getParameter("access_token");

		if (!StringUtils.hasText(accessToken)) {
			err(resp, "缺少 access_token 参数");

			return false;
		}

		Object object = req.getSession().getAttribute(accessToken);

		if (object == null) {
			// TODO 是否拿 Token 去 SSO 中心再校验一下
			err(resp, "非法 AccessToken");

			return false;
		} else {
		}

		UserSession userSess = (UserSession) object;

		// 如果 Access Token 已经失效,则返回错误提示
		if (checkIfExpire(userSess.accessToken)) {
			// TODO 是否要删除过期 token?
			err(resp, "access_token 已超时");
			return false;
		} else
			return true;
	}

	/**
	 * 获取 expiresIn 与当前时间对比,看是否超时
	 * 
	 * @param token 令牌
	 * @return true 表示超时
	 */
	static boolean checkIfExpire(AccessToken token) {
		long expiresIn = token.getExpiresIn();
		LocalDateTime expiresDateTime = LocalDateUtils.ofEpochSecond(expiresIn);// 过期日期
		return expiresDateTime.isBefore(LocalDateTime.now());
	}

	static void err(HttpServletResponse resp, String msg) {
		resp.setStatus(HttpStatus.UNAUTHORIZED.value());
		resp.setHeader("Content-type", "application/json;charset=UTF-8");

		try {
			resp.getWriter().write(BaseController.jsonNoOk(msg));
		} catch (IOException e) {
			e.printStackTrace();
		}
	}
}

SSO Client

上面所述的所有代码都在 SSO Client 这个工程中,可以通过 Maven 加入到你的工程中。

设置 Session 超时时间,在 web.xml 配置一下。

<!-- 时间单位为分钟   -->  
<session-config>
      <session-timeout>15</session-timeout>
</session-config>

Spring Boot 设置 yml

server:
   port: 8089
   session:
      timeout: 1800  #以秒为单位

Java 设置:

session.setMaxInactiveInterval(30*60;//以秒为单位

附录:多种认证、授权模型的比较

https://chanjarster.github.io/posts/compare-authc-authz-models/

本文主要列举在如今前后端分离、手机 App 大行其道的现状下,用户认证、授权的几种做法及对比。

PS. 本文假设你已经理解了各种认证模式的具体细节。

OAuth2.0 的几种模式

OAuth2.0 是一个被广泛采用的事实标准,它同时包含认证和授权两种模式,我们来看一下它有几种模式:

在这里插入图片描述

名词定义:

User: 自然人。
Client: 索要Authorization Code和Access Token的程序。

Client owner:

First-party: 第一方client,即client开发者/厂商和Resource Server是同一个人/厂商。
Third-party: 第三方client,即client开发者/厂商和Resource Server不是同一个人/厂商。OAuth 2.0主要解决的是第三方client的授权问题。

User context:

Y: 代表被授权的资源是和当前User相关的。
N: 代表被授权的资源是和Client相关的。

Client type:

Confidential: 这类Client和Authorization Server/Resource Server的通信是秘密进行的。
Public: 这类Client和Authorization Server/Resource Server的通信是公开进行的。

App type:

web app: 这类App的代码在服务器上执行,用户通过User-Agent(浏览器)下载App渲染的HTML页面,并与之交互。比如,传统的MVC应用。
user-agent app: 这类App的代码是直接下载到User-Agent(浏览器)里执行的。比如,前后端分离App、SPA。
native app: 这类App安装在用户的设备上,可以认为这类App内部存储的credential信息是有可能被提取的。比如,手机App、桌面App。

仅做认证的模式

在这里插入图片描述

详细说明以上三种模式:

Session模式: 就是我们传统的Web app所使用的技术,用户输入账号和密码登录系统,服务端返回一个名字叫做SESSIONID的Cookie,之后User-agent和服务端每次交互都会携带这个Cookie,通过这种方式来做到用户登录状态的保持。

SSO模式: 其实是Session模式的变种,只不过把认证从Session模式的本地认证变成了利用SSO服务器做认证。已知SSO类型有:CAS、SAML。

JWT模式: 它和Session模式的区别在于:

用户会话信息不通过Cookie携带,而是放在Header里,这个信息我们叫做Token。
Token里包含了加密的、不可篡改的当前登录用户的信息,SESSIONID只是一个代号,是没有这个信息的。
服务端可以做到无状态,因为用户信息在Token里已经存在,再也不需要维护Session了。

JWT模式可以使用SSO吗?答案是可以的,但是有条件,在SSO认证流程的最后一步——获取用户信息——的通信必须是confidential的。

对于Web app来说只要它接入了SSO,获取用户信息的通信本来就是confidential的,它获得用户信息之后构造JWT并返回就可以了。

对于User-agent app和Native app来说,需要为它做一个中介Web app,这个Web app和SSO通信,然后构造JWT返回给User-agent app。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

sp42a

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值