OAuth 2.0(四):手把手带你写代码接入 OAuth 2.0 授权服务

大家好,我是橘长,昨天分享了 「 令牌机制  」,大家可以自行回顾一下 OAuth 2.0 的核心是什么,令牌机制是什么、如何使用、开源组件等。

今天我们开始落地写代码,基于橘长之前接入过农业银行的授权,今天首先作为第三方服务来和大家分享「 手把手接入 OAuth 2.0 授权服务」。

一、业务背景

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

OAuth 2.0(四):手把手带你写代码接入 OAuth 2.0 授权服务

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

这就是为什么橘长这边需要接入 行方 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 给前端,前端会请求服务端的发起授权接口。

OAuth 2.0(四):手把手带你写代码接入 OAuth 2.0 授权服务

// 比方说访问活动首页接口
curl https://xxx.xx.xx/api/activity/act-01/index;
​
// 服务端响应 401
{
    "message": "用户会话已过期,请重新登录!",
    "statusCode": 401
}

2)第二步:客户端收到 401,请求授权接口

第三方软件后端会和授权服务交互,获取授权页地址,然后 302 跳转引导用户到授权页。

  • 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 请求等,最关键的是这是一个独立模块,可以锻炼模块设计能力。

下一篇橘长将给大家带来「 手把手搭建 OAuth 2.0 授权服务 」的解读,感谢你的关注,如果你觉得有所收益,欢迎点赞、转发、评论,感谢认可!

有任何疑问也可以添加我的好友(wx:mbandtr),欢迎沟通。

上一篇:有关OAuth 2.0简化模式中步骤D-F的解释


下一篇:asp.net core认证与授权:Oauth2.0概念及在.net core中的实现