手把手带你接入 OAuth 2.0 授权服务

简介: 支付宝等开放平台授权登录场景底层原理 OAuth 2.0 授权技术。OAuth 2.0 四个关键角色:客户端、第三方软件、授权服务、受保护资源。本文从第三方软件角度切入,带你手把手接入授权服务,引导用户跳转授权服务授权页,授权服务颁发授权码给第三方软件,第三方软件使用授权码和授权服务交互换取访问令牌,最后通过访问令牌请求授权服务的受保护资源(这块用 api ),进而实现一整套 OAuth 2.0 授权许可许可机制流程。

大家好,我是橘长,今天和大家分享『 如何手把手带你接入 OAuth 2.0 授权服务 』。

当大家在访问阿里云或阿里生活号等业务场景的时候会发现应用提供了一种扫码登录的登录方式,它提示用户说打开支付宝扫码,当扫码之后页面会告知您将登录 xxx 平台,登录之后再次显示 xxx 应用将获取您在 xxx 平台的某些资料。

这个场景背后的实现原理就是 OAuth 2.0 授权协议,OAuth 2.0 中有四个关键角色,分别是:客户端、第三方软件、授权服务、受保护资源,它提供的授权方式也多种多样,有隐式许可机制、客户端凭据许可机制、授权码许可机制等,本文从第三方软件角度接入授权服务,实现 OAuth 2.0 中最安全最完备的 授权码许可机制授权协议。

一、业务背景

图片.png

近期团队帮银行做了一个互动营销活动,活动入口在行方的 App 上,当用户在行方 App 点击活动 banner 页跳转活动的时候参与。

在进活动之前作为业务方自然需要知道参与活动的人是谁,如何给它构建登录态。

这就是为什么橘长这边需要接入 行方 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 跳转引导用户到授权页。

图片.png

  • 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 {
        // 采用 开源工具 hutool
        String 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) {
    // 生成 业务 code
    String 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());
    }
    // 删除掉 code
    cache.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 请求等。

感谢你的关注,如果你觉得有所收益,欢迎点赞、转发、评论,感谢认可!

有任何疑问私信我,欢迎沟通。

相关实践学习
【涂鸦即艺术】基于云应用开发平台CAP部署AI实时生图绘板
【涂鸦即艺术】基于云应用开发平台CAP部署AI实时生图绘板
相关文章
|
存储 安全 JavaScript
【分布式技术专题】「授权认证体系」深度解析OAuth2.0协议的原理和流程框架实现指南(授权流程和模式)
在传统的客户端-服务器身份验证模式中,客户端请求服务器上访问受限的资源(受保护的资源)时,需要使用资源所有者的凭据在服务器上进行身份验证。资源所有者为了给第三方应用提供受限资源的访问权限,需要与第三方共享它的凭据。这就导致一些问题和局限:
1201 2
【分布式技术专题】「授权认证体系」深度解析OAuth2.0协议的原理和流程框架实现指南(授权流程和模式)
|
8月前
|
安全 IDE API
Python类型提示进阶:告别“动态一时爽,重构火葬场”
Python类型提示让动态语言更可靠:通过静态类型注解提升代码可读性、重构效率与团队协作体验,结合mypy、Pydantic等工具链,实现从开发到运行时的全链路类型安全,平衡灵活性与工程化需求。(238字)
|
Ubuntu 编译器 C语言
在Ubuntu22.04平台上交叉编译针对Rv1126架构的GCC13.2.0编译器的步骤。
遵循上述步骤,您应该能够在Ubuntu 22.04平台上成功交叉编译适用于RISC-V架构RV1126的GCC 13.2.0编译器,允许您为目标硬件构建应用程序和操作系统组件。
796 10
|
9月前
|
数据采集 存储 调度
农业爬虫实战:惠农网农产品价格行情抓取全解析
农业爬虫助力现代农业数字化转型,通过实时抓取全国2000+市场农产品价格,解决传统数据采集滞后、覆盖窄等问题。以Python为核心技术,结合Requests、Selenium、代理池等工具,实现高效、稳定的数据获取。应用于种植决策、物流调度与价格预测,推动农业智能化发展。(238字)
1044 0
|
机器学习/深度学习 设计模式 API
Python 高级编程与实战:构建微服务架构
本文深入探讨了 Python 中的微服务架构,介绍了 Flask、FastAPI 和 Nameko 三个常用框架,并通过实战项目帮助读者掌握这些技术。每个框架都提供了构建微服务的示例代码,包括简单的 API 接口实现。通过学习本文,读者将能够使用 Python 构建高效、独立的微服务。
|
文件存储
将阿里云盘挂载到本地
用CloudDrive将阿里云盘挂载到本地
17354 0
将阿里云盘挂载到本地
|
机器学习/深度学习 自然语言处理 并行计算
探索深度学习与自然语言处理的最新进展
探索深度学习与自然语言处理的最新进展
620 4
|
人工智能 监控 物联网
深度探索人工智能与物联网的融合:构建未来智能生态系统###
在当今这个数据驱动的时代,人工智能(AI)与物联网(IoT)的深度融合正引领着一场前所未有的技术革命。本文旨在深入剖析这一融合背后的技术原理、探讨其在不同领域的应用实例及面临的挑战与机遇,为读者描绘一幅关于未来智能生态系统的宏伟蓝图。通过技术创新的视角,我们不仅揭示了AI与IoT结合的强大潜力,也展望了它们如何共同塑造一个更加高效、可持续且互联的世界。 ###
|
机器学习/深度学习 自然语言处理 机器人
编码器-解码器(Encoder-Decoder)结构
编码器-解码器(Encoder-Decoder)结构
2402 5

热门文章

最新文章