一、业务背景
近期团队帮银行做了一个互动营销活动,活动入口在行方的 App 上,当用户在行方 App 点击活动 banner 页跳转活动的时候参与。
![](https://i-blog.csdnimg.cn/blog_migrate/3b0f344f7236605963b0cc99aa50cda4.jpeg)
在进活动之前作为业务方自然需要知道参与活动的人是谁,如何给它构建登录态。
这就是为什么橘长这边需要接入 行方 OAuth 2.0 组件的原因,本质就是获取 客户信息,回到活动业务形成登录态,进而可以参与活动。
使用的是 OAuth 2.0 中最完备的 授权许可机制 的这种接入方式,服务端发起型授权,为了方便展示,大部分采用了硬编码。
二、作为第三方软件需要做什么
1、静态注册
也可以称之为备案(注册信息) ,需要在 行方 的开放平台的管理态申请 OAuth 2.0 接入客户端,对于行方来说,确保第三方软件是可信的。
准备信息:第三方应用服务端 ip 地址、第三方应用回调通知地址、申请权限等。
String ip = "xxx.xx.xx.xx";
String callBackUrl = "https://xxx.xxx.xx/oauth/login/success";
等授权服务的后台人员处理之后会颁发相关配置,用来表示唯一标识 第三方软件 的相关配置。
如下是一个简单示例模板:
String appid = "appid_001";
String appSecret = "appSecret_001";
String scope = "user_info";
2、引导用户授权
1)第一步:用户访问第三方软件,判断凭据
如果没有携带 JWT 或 已过期,服务端响应 Http 状态码 401 给前端,前端会请求服务端的发起授权接口。
// 比方说访问活动首页接口
curl https://xxx.xx.xx/api/activity/act-01/index;
// 服务端响应 401
{"message": "用户会话已过期,请重新登录!","statusCode": 401
}
2)第二步:客户端收到 401,请求授权接口
第三方软件后端会和授权服务交互,获取授权页地址,然后 302 跳转引导用户到授权页。
![](https://i-blog.csdnimg.cn/blog_migrate/bd185e2212601e940371f70302e39042.jpeg)
- controller 层
@Slf4j
@RequestMapping("/oauth")
@RestController
public class OAuthController {@GetMapping("/login")public RedirectView login(final String redirect) {String sceneId = IdUtil.simpleUUID();JSONObject sceneInfo = JSONUtil.createObj().putOnce(OAuthConstant.REDIRECT_FOR_FRONT, redirect);// 缓存前端通知地址cache.set(OAuthConstant.KEY_PRE_OAUTH_SCENE + sceneId, sceneInfo, 300);String oauthUrl = oAuthService.buildOauthUrl(sceneId, callbackUrl);log.info("[发起 xxx 行方 OAuth 授权] 构建OAuth url为:[{}]", oauthUrl);// 302 跳转return new RedirectView(oauthUrl);}
}
日志打印:
xxxx [发起授权] 构建的授权地址为:[http://xxx/oauth/authorize?client_id=xxx&redirect_uri=http%3A%2F%2Fxxx%2F%2Foauth%2Flogin%2Fsuccess&state=d8cb3943cd3a45818711fa4f6a8820e9&scope=custid%2Cphone&response_type=code]
- service 实现
@Override
public String buildOauthUrl(final String sceneId) {// 回调地址String callbackUrl = applicationConfig.getAppUrl() + "/oauth/login/success";// 带参String notifyUrl = UrlBuilder.of(callbackUrl, CharsetUtil.CHARSET_UTF_8).addQuery("sceneId", sceneId).build(); return xxxOAuthService.buildOauthUrl(notifyUrl);
}
service 这层根据标准 OAuth 2.0 的要求更合理做法是 请求授权服务获取,这里授权服务设计有点不合理,后续调整。
3)第三步:授权服务回调通知,分发临时授权码
@RequestMapping("/login/success")
public RedirectView loginSuccess(final String sceneId, final String code) { log.info("[xxx 行方授权回调通知] sceneId:[{}], code:[{}]", sceneId, code);// 后续业务操作
}
日志打印:
xxxx [oauth 服务]-回调,接收到数据为:code:[xxx], state:[xxx]
4)第四步:第三方服务通过 code 换取 token
private String getToken(final String clientId, final String clientSecret, final String code) {JSONObject tokenJson = this.getTokenFromOAuthServer(clientId, clientSecret, code, redirectUri);String accessToken = tokenJson.getStr("access_token");if (!JSONUtil.isNull(tokenJson) && StrUtil.isNotEmpty(accessToken)) {return accessToken;}throw new RuntimeException("token获取异常!");
}
/**
* 从 授权服务 获取 token
*
* @author huangyin
*/
private JSONObject getTokenFromOAuthServer(final String clientId, final String clientSecret, final String code, final String redirectUri) { // 请求资源地址 String requertUrl = oauthServerConfig.getBaseUrl() + "/oauth/token";// 构建请求参数Map<String, Object> formMap = new HashMap<>(5);// 授权码许可模式formMap.put("grant_type", "authorization_code");formMap.put("client_id", clientId);formMap.put("client_secret", clientSecret);formMap.put("code", code);// http 请求JSONObject response = this.doPostFormData(requertUrl, formMap);log.info("[从授权服务获取 token] 结果为:[{}]", response);return response;
}
/**
* 抽离 post 请求方法,form-data 传参
*
* @author huangyin
*/
private JSONObject doPostFormData(final String sourceUrl, final Map<String, Object> formArgs) {try {// 采用 开源工具 hutoolString response = HttpRequest.post(sourceUrl).form(formArgs).timeout(3000).execute().body();log.info("[从授权服务post form请求] 请求地址:[{}],请求参数:[{}],原始响应:[{}]", sourceUrl, formArgs, response);if (JSONUtil.isJson(response)) {// 依据授权服务 api response 定义做结果处理JSONObject responseJson = JSONUtil.parseObj(response);String code = responseJson.getStr("code");JSONObject data = responseJson.getJSONObject("data");if ("0000".equals(code) && !JSONUtil.isNull(data)) {return data;}}} catch (Exception e) {log.error("[从授权服务 post form 请求] 异常,请求地址:[{}],参数:[{}],异常信息:[{}]", sourceUrl, formArgs, e.getMessage());}throw new RuntimeException("授权服务异常!");
}
打印日志:
xxxx [授权服务 code 获取 token] 结果为:[{"access_token":"xxx","expires_in":7200}]
5)第五步:拿到 凭据,访问业务接口
当用授权码换取到 凭据 之后,通过凭据去获取用户在受保护资源服务的数据,比方说获取用户信息。
public String getUserInfoFromOAuthServer(String token) {String sourceUrl = oauthServerConfig.getBaseUrl() + "/oauth/userInfo";try {// header 头部方式提交 凭据String response = HttpRequest.post(sourceUrl).header("Authorization", "Bearer " + accessToken).timeout(3000).execute().body();log.info("[从授权服务 post 请求获取用户信息] 请求地址:[{}],原始响应:[{}]", sourceUrl, response);if (JSONUtil.isJson(response)) {// 依据授权服务 api response 定义做结果处理JSONObject responseJson = JSONUtil.parseObj(response);String rtCode = responseJson.getStr("code");String data = responseJson.getStr("data");if ("0000".equals(rtCode) && StrUtil.isNotEmpty(data)) {return data;}}} catch (Exception e) {log.error("[从授权服务 post 请求获取用户信息] 异常,请求地址:[{}],异常信息:[{}]", sourceUrl, e.getMessage());}throw new RuntimeException("授权服务异常!");
}
打印日志:
xxxx [从授权服务换取用户信息] 解密出来的用户信息为:[{"openid":"xxx","headImg":"xxx"}]
6)第六步:用户信息入库,分发业务 code 给前端
拿到用户信息,写入活动服务的业务表中,然后通知前端说授权完成啦,颁发活动业务的 临时码(code)给客户端,便于客户端来换取活动业务的 JWT。
- 用户信息入库
public RedirectView loginSuccess(String ...) {// 拷贝OauthUser oauthUser = BeanUtil.copyProperties(userInfoDto, OauthUser.class);oauthUserService.createOrUpdate(oauthUser);// 通知前端return this.redirectFrontEndUrl(state, userInfoDto);
}
- 服务端通知前端
private RedirectView redirectFrontEndUrl(final String sceneId, final UserInfoDto userInfoDto) {// 生成 业务 codeString businessCode = IdUtil.simpleUUID();try {// 反查拿到前端通知地址cache.set(SmallBeanOauthConstant.SMALL_KEY_PRE_USER_INFO + businessCode, userInfoDto, 300);JSONObject sceneInfo = JSONUtil.parseObj(cache.get(SmallBeanOauthConstant.SMALL_KEY_PRE_OAUTH_SCENE + sceneId));String redirectFrontEndUrl = sceneInfo.getStr("redirectFrontEndUrl");if (StrUtil.isEmpty(redirectFrontEndUrl)) {log.warn("授权:分发业务code给前端地址为空!");// TODO 这块需要设置默认值,或者是响应前端401,重跳授权} String notifyUrl = UrlBuilder.of(redirectFrontEndUrl, StandardCharsets.UTF_8).addQuery("code", businessCode).build();// 通知前端return new RedirectView(notifyUrl);} catch (Exception e) {log.error("[OAuth 颁发 code 给客户端异常] 授权id:[{}],异常信息:[{}], [{}]", sceneId, e.getMessage(), e.getCause());}throw new RuntimeException("授权失败,请稍后重试!");
}
7)第七步:客户端通过 code 换取 业务 jwt
/**
* 业务 code to jwt:构建登录态
*
* @param codeToJwtDto 分发的业务 code
* @return ResponseEntity
*/
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody CodeToJwtDto codeToJwtDto) {ValidatorUtil.validateEntity(codeToJwtDto);String code = codeToJwtDto.getCode();Object cacheUserInfo = cache.get(OAuthConstant.USER_INFO_PREFIX + code);if (null == cacheUserInfo) {throw new ParamException("code非法!", HttpStatus.UNPROCESSABLE_ENTITY.value());}// 删除掉 codecache.delete(OAuthConstant.USER_INFO_PREFIX + code);// 一系列其他业务处理等,然后生成 JWT Map<String, Object> tokenMap = this.buildJwt(activityUser);return ResponseEntity.status(HttpStatus.CREATED).body(tokenMap);
}
private Map<String, Object> buildJwt(ActivityUser activityUser) {// 构建 jwt 需要相关参数Map<String, Object> claims = new HashMap<>(2);claims.put("authId", activityUser.getId());claims.put("authRole", "user");String token = JwtUtil.generateToken(claims, applicationConfig.getExpiration(), applicationConfig.getTokenSigningKey());// 构建响应前端 token 信息Map<String, Object> tokenMap = new HashMap<>(3);tokenMap.put("accessToken", token);tokenMap.put("tokenType", "Bearer");tokenMap.put("expiresIn", applicationConfig.getExpiration());return tokenMap;
}
获取到的 JWT:
{
accessToken=header.payload.signature,
tokenType=Bearer,
expiresIn=86400
}
三、总结
今天橘长一步一步带着大家写代码手把手接入 OAuth 2.0 授权服务,大家需要记住几点:
1、关注 授权服务 的官方文档,开放平台接入文档是一个很重要的凭据。
2、第三方软件接入授权尽量采用 服务端发起型 授权,使用 授权码许可机制,因为这更安全、更完备。
3、强烈建议你手把手撸一遍,OAuth 2.0 接入的代码很考验基本功和代码风格,其中用到了 Redis 缓存、Hutool 工具去发起 Http 请求等。
下一篇橘长将给大家带来「 手把手搭建 OAuth 2.0 授权服务 」的解读,感谢你的关注,如果你觉得有所收益,欢迎点赞、转发、评论,感谢认可!