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

SSO 轻量级实现指南(原生 Java 实现):SSO Server 部分,第1张

OAuth 是当前单点登录(SSO)和用户授权的标准协议——现在就让我们一起动手撸一个 SSO 的实现吧!

源码在:

  • SSO 中心 https://gitee.com/sp42_admin/ajaxjs/tree/master/aj-sso
  • SSO Client 客户端 https://gitee.com/sp42_admin/ajaxjs/tree/master/aj-sso-client

我们开源的特色:

  • 轻量级,代码行数少
  • 除了 JVM 和 Spring 没啥依赖,尽量原生,基本没什么第三方引用库
  • 代码风格务求清晰、简洁易维护、干净

SSO Server 即 SSO 中心,负责统一用户认证的。另外有 SSO Client 部分,我们另起文章再讲。

SSO 与 OAuth 傻傻分不清?

开始之前先说说废话(之所以说废话的原因是,其实你可以无视这段概念性的介绍,直接开撸)。

OAuth 是 OAuth,OAuth 不单单为 SSO 服务的。OAuth 协议初衷是为了用户不用告诉第三方系统账号和密码就可以访问受限的资源,——可以成为 SSO 的通行协议这个想必原设计者都没有料到的。没有 OAuth 之前,SSO 老早就有,只是各家各法自行实现,总能达到单点登录的目的。也就是说,SSO 的协议不一定是 OAuth,而 OAuth 不一定服务于 SSO。

SSO 与 OAuth 都是紧扣“我是谁”之要义,即用户身份认证的问题,这也是核心的问题,所有关于用户一切的信息都应由 SSO 中心或 OAuth 资源服务器所把控。稍有出入的是 OAuth 认证服务器往往是与资源服务器在一起的,这个一起的意思可以是物理意义上的同一部机器。但 SSO 中心呢?一般简单、纯粹的得多,就是做用户认证的,——即使涉及用户权限的 SSO 中心,顶多也是功能性的、系统级的权限控制,而不是垂直的数据权限控制(资源的权限控制)。也就是说,SSO 中心不负责资源问题,而资源往往在客户端 Client 那边。总之,狭义的 OAuth 很可能是整个大系统中,对外服务的一个模块;而 SSO 中心则纯粹得多,通常独立部署,独立服务,只做好 SSO 一件事情。

在流程上,SSO 与 OAuth 也有显著不同,例如同意授权访问,典型的第三方登录是有这一步的(如下图所示),但 SSO 没有吧?

SSO 流程

SSO 流程如下(借图,来自这里)

用户登录/注销 登录 Login

当前是使用账号密码登录,未来也应该支持如微信、微博的第三方登录。登录的作于在于识别“我是谁”的目的,在 SSO 中心标识某某用户已经是在登录的状态,以实现“单点登录”。具体说,会产生关键的标识状态 Session 和浏览器 Cookies。Session 仍是记忆登录状态的重要信息,否则后面获取 Token 就无法进行(因为不知道哪个用户!)。

登录控制器 LoginController 源码在这里,关键的 Service 部分在这里。

登录成功或失败一般允许指 redirectUrl,但我们没有,因为当前这登录接口是跨域的,界面完全由客户端指定,所以客户端自己控制就好。不过感觉上登录界面放在 SSO 中心会安全一点吧,毕竟允许跨域了。

注销

当前注销只是清空 session 而已,但实际 SSO 复杂得多,理论上某个应用注销了,其他所有已登录的应用也有要同步注销。这部分暂且不表,待后面补充。

注册

注册分为用户注册和客户端注册。

用户注册

用户注册没什么好说的,常规流程的逻辑,参见源码。

客户端注册

接入的客户端有时也称“应用”。客户端模型如下面 SQL 所示。clientId 有时也称 appIdappKeyclientSecret 是密钥,但跟密码的意思没什么区别,肯定不能外泄出去。

CREATE TABLE `auth_client_details` (
	`id` INT(11) NOT NULL AUTO_INCREMENT COMMENT '主键 id,自增',
	`name` VARCHAR(20) NOT NULL COMMENT '客户端名称' COLLATE 'utf8mb4_bin',
	`content` VARCHAR(256) NULL DEFAULT NULL COMMENT '简介' COLLATE 'utf8mb4_bin',
	`clientId` VARCHAR(100) NOT NULL COMMENT '接入的客户端ID' COLLATE 'utf8mb4_bin',
	`clientSecret` VARCHAR(255) NOT NULL COMMENT '接入的客户端的密钥' COLLATE 'utf8mb4_bin',
	`redirecUri` VARCHAR(1000) NULL DEFAULT NULL COMMENT '回调地址' COLLATE 'utf8mb4_bin',
	`stat` TINYINT(4) NULL DEFAULT NULL COMMENT '数据字典:状态',
	`uid` BIGINT(20) NULL DEFAULT NULL COMMENT '唯一 id,通过 uuid 生成不重复 id',
	`extend` TEXT NULL DEFAULT NULL COMMENT '扩展 JSON 字段' COLLATE 'utf8mb4_bin',
	`tenantId` INT(11) NULL DEFAULT NULL COMMENT '租户 id',
	`creator` INT(11) NULL DEFAULT NULL COMMENT '创建者 id',
	`createDate` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '也是注册时间',
	`updateDate` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
	PRIMARY KEY (`id`) USING BTREE
)
COMMENT='接入的客户端信息表'
COLLATE='utf8mb4_bin'

clientIdclientSecret 都是随机字符串生成的,详见下面创建 client_details 部分。

@RestController
@RequestMapping("/oauth")
public class OauthController implements SsoDAO {
	/**
	 * 注册需要接入的客户端信息
	 * 
	 * @param client
	 * @return
	 */
	@PostMapping(value = "/clientRegister", produces = MediaType.APPLICATION_JSON_VALUE)
	public String clientRegister(@RequestParam ClientDetails client) {
		if (!StringUtils.hasText(client.getName()))
			throw new IllegalArgumentException("客户端的名称和回调地址不能为空");

		String clientId = StrUtil.getRandomString(24);// 生成24位随机的 clientId
		ClientDetails savedClientDetails = findClientDetailsByClientId(clientId);

		// 生成的 clientId 必须是唯一的,尝试十次避免有重复的 clientId
		for (int i = 0; i < 10; i++) {
			if (savedClientDetails == null)
				break;
			else {
				clientId = StrUtil.getRandomString(24);
				savedClientDetails = findClientDetailsByClientId(clientId);
			}
		}

		client.setClientId(clientId);
		client.setClientSecret(StrUtil.getRandomString(32));

		// 保存到数据库
		return ClientDetailDAO.create(client) == null ? BaseController.jsonNoOk() : BaseController.jsonOk();
	}
	……
}
SSO 登录

你以为上面用户登录就完事了?只是完成了三分之一,完整的单点登录还有其余的 66.6666……% ——我们接着看。

获取授权码

为什么要获取授权码(Authorization Code),不能直接返回 Token 吗?因水平所限我也不太清楚,好像为了安全性吧,好像 OAuth 有其他模式不用授权码的?我没去管了,反正最主流就是授权码模式。不懂得看官请琢磨上面的流程图,或者先去消化 OAuth 的机制。

获取授权码接口所需的参数参见 SsoController 控制器的方法,源码这里。

@Autowired
private AuthorizationService authService;

/**
 * 获取 Authorization Code
 * 
 * @param client_id    客户端 ID
 * @param redirect_uri 回调 URL
 * @param scope        权限范围
 * @param status       用于防止CSRF攻击(非必填)
 * @param req          请求对象
 * @return
 */
@RequestMapping(value = "/authorize_code", produces = BaseController.JSON)
public Object authorize(@RequestParam(required = true) String client_id,
// @formatter:off
	@RequestParam(required = true) String redirect_uri,
	@RequestParam(required = false) String scope,
	@RequestParam(required = false) String status,
	HttpServletRequest req) {
// @formatter:on
	LOGGER.info("获取 Authorization Code");

	User loginedUser = null;
	try {
		loginedUser = UserUtils.getLoginedUser(req);
	} catch (Throwable e) {
		LOGGER.warning(e);
		return SsoUtil.oauthError(ErrorCodeEnum.INVALID_CLIENT);
	}

	// 生成 Authorization Code
	String authorizationCode = authService.createAuthorizationCode(client_id, scope, loginedUser);
	String params = "?code=" + authorizationCode;

	if (StringUtils.hasText(status))
		params += "&status=" + status;

	return new ModelAndView("redirect:" + redirect_uri + params);
}

据此我们了解几个事实。

  • 只有用户登录了,才有对应的授权码。UserUtils.getLoginedUser(req); 这句从 Session 返回已登录的用户信息。
  • 用户哪个浏览器登录,就在哪个浏览器获取授权码,不然就是未登录状态。
  • 该接口只能前端调用
  • 该接口返回 HTTP 304 重定向,携带 code 参数(就是授权码)跳转到 redirect_uri。就是说该接口不会返回什么 JSON。

状态码有时效性,一般十分钟,而且是一次性的,用完了要销毁。

客户端接入 SSO 之第一步

从原理上讲,这也是客户端服务接入 SSO 的第一步(当然我们会提供一个封装好的 SDK,对于调用者是屏蔽细节的)。用户成功登录后,已在 SSO 中心留存有 Cookies 的登录信息,于是其他第三方应用可以访问 SSO 中心获取用户信息(当然不是直接获取,而且先要获取授权码)。

客户端可以通过授权码获取 AccessToken,然后再根据 AccessToken 获取用户信息,完成本地登录。总之我们提到了两次登录验证:第一次是用户身份验证(用户凭用户名和密码可以登录);第二次是客户端认证(客户端凭 id 和密钥再结合用户信息(授权码)去登录),这部分我们下面小结会详细讲。

生成授权码原理

进入 authService.createAuthorizationCode() 源码我们看看如何生成授权码。

/**
 * 根据 clientId、scope 以及当前时间戳生成 AuthorizationCode(有效期为10分钟)
 *
 * @param clientId 客户端ID
 * @param scope
 * @param user     用户信息
 * @return
 */
public String createAuthorizationCode(String clientId, String scope, User user) {
	if (!StringUtils.hasText(scope))
		scope = "DEFAULT_SCOPE";

	// 1. 拼装待加密字符串(clientId + scope + 当前精确到毫秒的时间戳)
	String str = clientId + scope + String.valueOf(System.currentTimeMillis());

	// 2. SHA1加密
	String encryptedStr = Digest.getSHA1(str);

	int timeout = ExpireEnum.AUTHORIZATION_CODE.getTime() * 60;
	// 3.1 保存本次请求的授权范围
	ExpireCache.CACHE.put(encryptedStr + ":scope", scope, timeout);
	// 3.2 保存本次请求所属的用户信息
	ExpireCache.CACHE.put(encryptedStr + ":user", user, timeout);

	// 4. 返回Authorization Code
	return encryptedStr;
}

主要是这么几步:1. 拼装待加密字符串(clientId + scope + 当前精确到毫秒的时间戳);2. SHA1 加密;3. 保存到缓存(不用保存在数据库)。

带过期时间的缓存大家想到的是 Redis,但我这里用了 JVM 的缓存,就是自己写的 Map,无他,懒得部署 Redis 了……

客户端认证(颁发 AccessToken)

客户端认证的过程就是颁发 AccessToken。我们看看客户端认证的源码定义,需要哪些参数。

/**
 * 通过 Authorization Code 获取 Access Token
 * 
 * @param client_id     客户端 id
 * @param client_secret 接入的客户端的密钥
 * @param code          前面获取的 Authorization Code
 * @param grant_type    授权方式
 * @param request       请求对象
 * @return
 */
@RequestMapping("/authorize")
public String issue(@RequestParam(required = true) String client_id,
// @formatter:off
	@RequestParam(required = true) String client_secret,
	@RequestParam(required = true) String code,
	@RequestParam(required = true) String grant_type,
	HttpServletRequest request) {
// @formatter:on
	LOGGER.info("通过 Authorization Code 获取 Access Token");

	// 校验授权方式
	if (!GrantTypeEnum.AUTHORIZATION_CODE.getType().equals(grant_type))
		return SsoUtil.oauthError(ErrorCodeEnum.UNSUPPORTED_GRANT_TYPE);

	ClientDetails savedClientDetails = findClientDetailsByClientId(client_id);
	// 校验请求的客户端秘钥和已保存的秘钥是否匹配
	if (!(savedClientDetails != null && savedClientDetails.getClientSecret().equals(client_secret)))
		return SsoUtil.oauthError(ErrorCodeEnum.INVALID_CLIENT);

	String scope = ExpireCache.CACHE.get(code + ":scope", String.class);
	User user = ExpireCache.CACHE.get(code + ":user", User.class);

	// 如果能够通过 Authorization Code 获取到对应的用户信息,则说明该 Authorization Code 有效
	if (StringUtils.hasText(scope) && user != null) {
		// 过期时间
		Long expiresIn = LocalDateUtils.dayToSecond(ExpireEnum.ACCESS_TOKEN.getTime());

		// 生成 Access Token
		String accessTokenStr = authService.createAccessToken(user, savedClientDetails, grant_type, scope, expiresIn);
		// 查询已经插入到数据库的 Access Token
		AccessToken authAccessToken = AcessTokenDAO.setWhereQuery("accessToken", accessTokenStr).findOne();
		// 生成 Refresh Token
		String refreshTokenStr = authService.createRefreshToken(user, authAccessToken);

		IssueToken token = new IssueToken(); // 返回数据
		token.setAccess_token(authAccessToken.getAccessToken());
		token.setRefresh_token(refreshTokenStr);
		token.setExpires_in(expiresIn);
		token.setScope(authAccessToken.getScope());

		return JsonHelper.toJson(token);
	} else
		return SsoUtil.oauthError(ErrorCodeEnum.INVALID_GRANT);
}

据此我们了解几个事实。

  • 该接口只能服务端调用。客户端密钥保存在服务端,不应暴露给前端。故所以认证客户端务必在服务端完成,即后台来通讯请求。
  • 进入该接口,要判断密钥是否正确
  • 授权码相当于获取缓存中的 key,value 就是用户信息
  • client 和 user 没问题之后,可以创建 AccessToken
  • AccessToken 保存到数据库。如果已有则再更新。
  • 还生成 RefreshToken,这将会后面讲
  • 这个 AccessToken 外表一堆字符串,实际蕴含什么意思呢?Token 不是密码但胜似密码,他内部包含了不仅用户信息还有客户端的信息,故 AccessToken = 用户+客户端(应用)的信息

至此就完成了登录了,进度……100%。

至于生成 Token 原理大家可以进入 Service 相关代码浏览,大致都是 SHA1 加密之类的,这里不再赘述。

实际设计中有两点“最佳实践”分享给大家。

  • 虽然有份“获取授权码”和“客户端认证”两个接口两个步骤,但前端一次请求就可以搞定了,这是在 SSO_Client 前端执行的。
  • 单纯返回 AccessToken 之外,最好还要返回用户的详细信息,不然又要前端请求多次。当然标准的 OAuth 没要求返回用户信息。不过目前我还去实现……有时间就搞
刷新 AccessToken-----> RefreshToken

一般 Token 时效性。

  • AccessToken,三十天
  • RefreshToken 365 日

当然,根据你的场景调整。

逻辑大同小异,我们直接贴代码。

/**
 * 通过 Refresh Token 刷新 Access Token
 * 
 * @param refresh_token
 * @return
 */
@RequestMapping(value = "/refreshToken", produces = MediaType.APPLICATION_JSON_VALUE)
public String refreshToken(@RequestParam(required = true) String refresh_token) {
	LOGGER.info("通过 Refresh Token 刷新 Access Token");
	RefreshToken authRefreshToken = RefreshTokenDAO.setWhereQuery("refreshToken", refresh_token).findOne();

	if (authRefreshToken == null)
		return SsoUtil.oauthError(ErrorCodeEnum.INVALID_GRANT);

	// 如果 Refresh Token 已经失效,则需要重新生成
	if (SsoUtil.checkIfExpire(authRefreshToken))
		return SsoUtil.oauthError(ErrorCodeEnum.EXPIRED_TOKEN);

	// 获取存储的 Access Token
	AccessToken authAccessToken = AcessTokenDAO.findById(authRefreshToken.getTokenId());
	// 获取对应的客户端信息
	ClientDetails savedClientDetails = ClientDetailDAO.findById(authAccessToken.getClientId());
	// 获取对应的用户信息
	User user = UserCommonDAO.UserDAO.findById(authAccessToken.getUserId());
	// 新的过期时间
	Long expiresIn = LocalDateUtils.dayToSecond(ExpireEnum.ACCESS_TOKEN.getTime());
	// 生成新的 Access Token
	String newAccessTokenStr = authService.createAccessToken(user, savedClientDetails, authAccessToken.getGrantType(), authAccessToken.getScope(), expiresIn);

	IssueToken token = new IssueToken(); // 返回数据
	token.setAccess_token(newAccessTokenStr);
	token.setRefresh_token(refresh_token);
	token.setExpires_in(expiresIn);
	token.setScope(authAccessToken.getScope());

	return JsonHelper.toJson(token);
}

有些厂家不是这么 RefreshToken 的,它是使用基本认证的方式验证客户端身份,如 Authorization: Basic ${Base64.encode(clientId+":"+clientSecret)}。可见它只需要客户端信息,不需要用户信息。

AccesToken 和 RefreshToken 怎么用呢?这就要看我们 SSO Client 如何调用了,——下篇文章再为大家介绍。

小结

SSO 中心没有想象中的难,当然还有其他周边的问题,如安全性的问题,或者用户权限那部分,会越做越复杂的。不管怎么样只要方向路线正确,那么干就是了!

推荐参考文章

  • OAuth2.0协议入门 ——非常不错,我也是参考其代码实现,它教会了我许多!
  • SSO 开源实现 Kisso
  • 基于 OAuth 2 的 smart-sso
  • XXL-SSO
  • IAM:MaxKey 国内开源IAM第一品牌
  • 旧帖《新浪微博如何实现 SSO 的分析》
  • 单点登录跨域iframe互相通信方案

欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/langs/727342.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-04-26
下一篇 2022-04-26

发表评论

登录后才能评论

评论列表(0条)

保存