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位随机的 clientIdClientDetails savedClientDetails = findClientDetailsByClientId(clientId);// 生成的 clientId 必须是唯一的,尝试十次避免有重复的 clientIdfor (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:onLOGGER.info("获取 Authorization Code");User loginedUser = null;try {loginedUser = UserUtils.getLoginedUser(req);} catch (Throwable e) {LOGGER.warning(e);return SsoUtil.oauthError(ErrorCodeEnum.INVALID_CLIENT);}// 生成 Authorization CodeString 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 Codereturn 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:onLOGGER.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 TokenString accessTokenStr = authService.createAccessToken(user, savedClientDetails, grant_type, scope, expiresIn);// 查询已经插入到数据库的 Access TokenAccessToken authAccessToken = AcessTokenDAO.setWhereQuery("accessToken", accessTokenStr).findOne();// 生成 Refresh TokenString 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);} elsereturn 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 TokenAccessToken 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 TokenString 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互相通信方案

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

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

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

  2. java kisso_java sso 基于 cookie 实现方案 kisso

    kisso 使用说明文档: ----------------------------------------------------------------- kisso 启动  web.xml 配置 ...

  3. Quarkus:一个Kubernetes原生Java框架

    Red Hat发布了Quarkus,这是一个为GraalVM和OpenJDK HotSpot量身定制的Kubernetes原生Java框架.Quarkus的目标是使Java成为Kubernetes和无 ...

  4. Quarkus 0.12.0 发布,下一代 K8s 原生 Java 框架

    开发四年只会写业务代码,分布式高并发都不会还做程序员?   Quarkus 0.12.0 发布了,Quarkus 是一个用于编写 Java 应用的云原生.容器优先框架. 此版本包含 213 个问题和 ...

  5. 标准化原生 Java:拉近 GraalVM 和 OpenJDK 的距离

    Java 主导着企业级应用.但在云计算领域,采用 Java 的成本比它的一些竞争对手更高.原生编译降低了在云端采用 Java 的成本:用它创建的应用程序启动速度更快,使用的内存更少. 那么,Java ...

  6. 低碳环保:无服务器和 Kubernetes 原生 Java 部署实践

    随着云部署的兴起,IT 部门使用的物理服务器减少,用电量也相应降低,结果是通过减少碳排放帮助缓解了气候变化.云架构有助于实现这一点,因为它们不需要维护竖井式的计算资源,而是在需要保持业务服务运行时,高 ...

  7. Jakarta EE:云原生Java的新平台

    \ 看新闻很累?看技术新闻更累?试试下载InfoQ手机客户端,每天上下班路上听新闻,有趣还有料! \ \\ 在今年的JAX大会上,Eclipse基金会的执行董事Mike Milinkovich专门介绍 ...

  8. 原生 Java 客户端进行消息通信

    原生 Java 客户端进行消息通信 Direct 交换器 DirectProducer:direct类型交换器的生产者 NormalConsumer:普通的消费者 MulitBindConsumer: ...

  9. BurpSuite插件开发指南之 Java 篇

    Her0in · 2016/05/27 16:53 此文接着 <BurpSuite插件开发指南之 API 下篇> .在此篇中将会介绍如何使用Java 开发 BurpSuite 的插件,重点 ...

最新文章

  1. 029 浏览器不能访问虚拟机的问题解决
  2. apache评分表的意义_APACHE评分系统及评分表
  3. Placements(连接)
  4. Jsp之五 过滤器与监听器
  5. ninject 的 实现 的 理解
  6. Powerdesigner数据库建模工具教程
  7. 优化案例(part4)--A novel consensus learning approach to incomplete multi-view clustering
  8. 机器学习实战(六)——支持向量机
  9. C++学习—— mutable和 extern
  10. 动态规划——最大整除子集C++
  11. python调用通达信函数_如何把通达信公式变成python
  12. java通过TscLibDll调用佳博热敏票据打印机(580130IVC)打印小票
  13. java如何通过拼音搜索功能_如何实现拼音搜索
  14. Linux日志管理工具 journalctl
  15. 小鸟云服务器如何进行重装系统?
  16. 大数高精度加减、乘除、开根(C++版全套最详细、最易懂)
  17. 女生找工作,非常有用,好好 收藏,以后肯定能用得上 (转)
  18. MemFire教程|PostgreSQL RLS介绍
  19. MFC 的 Picture Control 加载 BMP/PNG 图片的方法
  20. turfjs前端地理空间分析类库

热门文章

  1. (八)Springboot整合Redis(RedisTemplate,使用Junit进行测试)
  2. Canvas 实现刮刮乐 js实现刮刮乐
  3. Python读Word里的表格
  4. java计算机毕业设计基于springboo+vue的电脑城销售系统
  5. mysql建立班级表_mysql数据表设计-班级表 学生表 老师表 课程表 成绩表
  6. Linux和Ubuntu是什么关系
  7. JVM性能优化(四)提高网站访问性能之Tomcat优化
  8. 10 款最适合编程的字体
  9. 对Web登录全面认识
  10. html骰子图片点数,html骰子