SSO登录URL路由重定向与回调版本实现一篇搞定

单点登录概念

维基百科关于SSO概念的介绍

单点登录技术方案介绍

1.前端获取授权码(前端重定向 + 后端鉴权)

流程示意图:

优点

用户体验更好:前端直接处理用户重定向,用户可以在浏览器中看到登录流程,感觉更加直观。

开发灵活性高:前端可以更灵活地处理用户界面和交互逻辑。

适配性较好:适用于大多数现代Web应用,尤其是单页面应用(SPA),因为这种模式允许前端完全控制用户界面。

缺点

安全性稍低:授权码在前端传递,虽然授权码本身是临时的且有时间限制,但仍然存在一定的风险。

前后端分离要求高:需要前后端紧密配合,前端必须正确提取授权码并传递给后端。

2.后端获取授权码(后端重定向 + 后端处理回调)

流程示意图:

优点

安全性更高:整个授权流程在后端完成,授权码和访问令牌不会暴露给前端,减少了安全风险。

适合传统Web应用:对于传统的服务器端渲染应用,这种方式更加自然,后端可以直接处理用户重定向和回调。

缺点

用户体验稍差:用户可能会感觉到页面跳转是由后端控制的,不如前端重定向直观。

开发复杂度高:后端需要处理用户重定向和回调逻辑,开发复杂度相对较高。

两者总结与个人感受:

前端获取授权码:这种方式在现代Web开发中更为常见,尤其是在单页面应用(SPA)中。它能够更好地适配现代Web开发框架(如React、Vue.js等),并且可以提供更流畅的用户体验。

后端获取授权码:这种方式在传统的Web应用中更为常见,特别是在企业级应用中,安全性是首要考虑因素。

个人感受:涉及银行类项目,可能会偏向于全程后端获取授权码的方式。

后端获取授权码完成SSO方案介绍与实现

之前的文章中,上一篇写钉钉H5端登录已经实现了前后端结合获取授权码进行实现SSO登录,本次主要记录的是后端获取授权码会遇到的坑点和注意事项。

基本接口

后端实现需要对服务增加两个接口,分别是一个路由重定向****和回调**接口。

路由重定向接口的作用:构建向三方鉴权平台鉴权路径并拼接上回调接口路径完成鉴权,或者可以说就是将前后端结合,需要前端去三方鉴权平台获取一次性鉴权码的操作,在这一步进行实现。下述的相关的伪代码展示大致的思路:

下述的代码中,有一个类主要注意,UriComponentsBuilder解析原始URL。使用整个类进行解析。

为什么使用 UriComponentsBuilder 不容易出错?

自动处理 URL 编码

在构建 URL 时,尤其是添加查询参数时,很容易因为忘记对参数值进行编码而引入错误。例如,如果参数值包含特殊字符(如 &、?、= 等),直接拼接会导致 URL 格式错误。

UriComponentsBuilder 会自动对查询参数的值进行 URL 编码,避免了手动编码的麻烦和潜在的错误。

结构化处理 URL 组件

URL 由多个部分组成,包括协议、主机、端口、路径和查询参数等。手动拼接 URL 时,很容易遗漏某些部分或错误地拼接它们。

UriComponentsBuilder 提供了一种结构化的方式来处理这些组件,通过方法链的方式逐步构建 URL,每个部分都通过专门的方法处理,减少了拼接错误的可能性。

避免字符串拼接错误

手动拼接 URL 时,很容易因为遗漏分隔符(如 / 或 :)或错误地添加多余的分隔符而导致 URL 格式错误。

UriComponentsBuilder 通过方法调用自动处理这些分隔符,确保生成的 URL 格式正确。

异常处理机制

如果输入的原始 URL 格式不正确(例如缺少协议、主机名等),UriComponentsBuilder 会在解析时抛出异常,而不是生成一个错误的 URL。这使得开发者能够及时发现并处理问题,而不是在后续的逻辑中才发现 URL 无效。

UriComponentsBuilder 的原理是什么?

基于 URI 组件的拆分和重组

UriComponentsBuilder 内部将 URL 拆分为多个组件,包括:

协议(Scheme):例如 http 或 https。

主机(Host):例如 example.com。

端口(Port):例如 8080。

路径(Path):例如 /api/login。

查询参数(Query Parameters):例如 ?key=value&anotherKey=anotherValue。

片段(Fragment):例如 #section1。

在解析原始 URL 时,它会将这些组件分别提取出来,存储在内部的数据结构中。

在构建新的 URL 时,它会根据这些组件重新组合,确保每个部分都符合 URL 的规范。

/**

* 获取 SSO 登录的 URL

* @param response

* @throws IOException

*/

@GetMapping("/userLogin")

@Operation(summary = "SSO登录跳转")

public void ssoLoginRedirection(HttpServletResponse response) throws IOException {

try {

String loginUrl = UserAuthService.buildLoginUrl();

// 检查登录 URL 是否有效

if (ObjectUtil.isNotEmpty(loginUrl)) {

// 重定向到 SSO 登录 URL

response.sendRedirect(loginUrl);

} else {

// 如果登录 URL 无效,发送 400 错误响应

response.sendError(HttpServletResponse.SC_BAD_REQUEST, "无效的登录 URL");

}

} catch (IOException e) {

// 记录异常并发送 500 错误响应

e.printStackTrace(); // 或者使用日志框架记录

response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "重定向到登录页面时发生错误");

}

}

// 核心代码是下述

/**

* 构建SSO登录URL

* @return

*/

@Override

public String buildLoginUrl() {

// 获取配置鉴权信息

xxxxxxxxxxxxxx

// 断言进行优雅非空校验

Assert.notNull(SsoPlatformConfig, () -> new BizException(ErrorCodeEnum.IS_CONFIGURED));

LoginH5UserReq threePartyInfo = new LoginH5UserReq();

threePartyInfo.parseApiUrlsFromJson(SsoPlatformConfig.getAddressTheRequest());

// 解析基础URL

try {

String baseUrl = threePartyInfo.getAccessTokenUrl();

// 使用UriComponentsBuilder解析原始URL

UriComponents originalUri = UriComponentsBuilder.fromHttpUrl(fullUrl).build();

// 重新构建基础URL,不包含查询参数

String baseUrl = originalUri.getScheme() + "://" + originalUri.getHost() +

(originalUri.getPort() == -1 ? "" : ":" + originalUri.getPort()) +

originalUri.getPath();

下面这块代码进行编码是解决实现下述回调接口需要接受必要的查询参数,需要使用这种方式进行编码然后在回调接口进行解码就可以实现在路由重定向之后完整了三方平台的鉴权之后,由于本次使用的OA2协议,因此在该协议之下是不会含有自定义的字段,因此需要使用这种方式进行解决参数传递的问题。

// 将额外参数打包成一个JSON字符串

Map extraParams = new HashMap<>();

extraParams.put("typePlatForm", "xxxxxxxxxxxxxx");

extraParams.put("terminalType", "xxxxxxxxxxxxxx");

String state = new ObjectMapper().writeValueAsString(extraParams);

// Base64编码

state = Base64.getUrlEncoder().encodeToString(state.getBytes());

return UriComponentsBuilder.fromHttpUrl(baseUrl)

.queryParam("response_type", "code")

.queryParam("client_id","xxxxxxxxxxxxxx")

.queryParam("redirect_uri", "xxxxxxxxxxxxxxxx")

.queryParam("state", state)

.toUriString();

} catch (Exception e) {

throw new IllegalArgumentException("Invalid AccessTokenUrl: " + threePartyInfo.getAccessTokenUrl(), e);

}

}

回调接口作用:

下述代码中涉及到的相关逻辑如何优雅实现,在之前的文章中如何实现H5端对接钉钉登录并优雅扩展其他平台整个有介绍,全网直接搜索就可以搜索到。

/**

* 处理SSO回调请求

* @param code 一次性鉴权码

* @param state 自定义加密查询参数

* @param session

* @param response

* @return

* @throws IOException

*/

@GetMapping("/callback")

@Operation(summary = "用户登录回调接口")

public Result callback(@RequestParam("code") String code,

@RequestParam("state") String state, HttpSession session, HttpServletResponse response) throws IOException {

Result loginRespResult = null;

try {

// 调用服务层处理回调请求

loginRespResult = UserAuthService.handleSSOCallback(code,state, session, response);

} catch (Exception e) {

e.printStackTrace();

// 处理异常并返回500错误

response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "处理回调请求时出错");

}

return loginRespResult;

}

// 具体的实现类伪代码说明

/**

* 处理从SSO系统回调的请求,包括获取访问令牌、用户信息和生成JWT令牌

* @param code 从SSO系统返回的授权码

* @param session 当前会话,用于存储访问令牌

* @param response HTTP响应,用于重定向用户或返回错误信息

*/

@Override

public Result handleSSOCallback(String code,String state,HttpSession session, HttpServletResponse response) throws ApiException {

// 解析state参数

String typePlatForm = null;

String terminalType = null;

if (state != null && !state.isEmpty()) {

try {

byte[] decodedBytes = Base64.getUrlDecoder().decode(state);

String decodedState = new String(decodedBytes);

Map extraParams = new ObjectMapper().readValue(decodedState, Map.class);

// 使用这些额外参数

typePlatForm = extraParams.get("typePlatForm");

terminalType = extraParams.get("terminalType");

// 处理业务逻辑...

} catch (Exception e) {

// 处理解析错误

}

}

// 获取配置鉴权信息

xxxxxxxxxxxxxx

// 获取三方登录配置信息

ObjectMapper objectMapper = new ObjectMapper();

xxxxxxxxxxxxxxxxxxxxxxxx

// 单独置入解析规则

loginH5UserReq.setJsonRules(SsoPlatformConfig.getJsonRules());

// 置入重定向地址

loginH5UserReq.setRedirectUri(SsoPlatformConfig.getRedirectUri());

String userUniqueIdentifier = H5TaiZhouBankAuthHandler.getUserDetail(loginH5UserReq);

log.info("当前获取唯一用户标识字段:{}", userUniqueIdentifier);

// 系统校验更具手机号查询用户信息

SysUser sysUser = sysUserMapper.selectOne(Wrappers.lambdaQuery(SysUser.class).eq(SysUser::getTel, userUniqueIdentifier), false);

log.info("根据用户标识字段获取的系统内用户信息:{}", sysUser.toString());

Assert.notNull(sysUser, () -> new BizException(ErrorCodeEnum.NOT_AVAILABLE));

// 手机号停用状态判断

Assert.isTrue(sysUser.getState() != 0, () -> new BizException(ErrorCodeEnum.IS_DEACTIVATE));

// token权限下发

xxxxxxxxxxxxx

// 查询组织

xxxxxxxxxxxx

return Result.success(loginResult);

}

如何进行测试

本次还是遇到一个问题,就是涉及三方的鉴权平台是内网部署,公网的环境之下访问不了不能联调?如何能初步的进行校验解决一下~,可以看一下下面这篇文档进行使用阿里云的OA2进行取巧验证一下。下面简单进行说明一下需要如何配置,具体详细的可以访问整个链接进行查看:

https://apifox.com/blog/oauth-2/#%E6%AD%A5%E9%AA%A4-2%EF%BC%9A%E9%85%8D%E7%BD%AE%E6%8E%88%E6%9D%83%E7%A0%81%E7%9A%84%E8%AF%B7%E6%B1%82%E5%9C%B0%E5%9D%80

创建阿里客户端

Apifox配置

配置完验证

只要能实现跳转校验就说明基本鉴权这块没有问题,还有就是自定义的state参数传递也没有问题。只要是后面的解析有问题那就是改改查询逻辑就比较好排查了