OAuth2接入第三方认证平台实战(登录,注销,当前用户,自定义异常,白名单)

1 演示

先来演示一波,再进行讲解

(1)不携带token访问资源

不携带token无法访问资源
OAuth2接入第三方认证平台实战(登录,注销,当前用户,自定义异常,白名单)_第1张图片
(2)登录

登录之后获取token
OAuth2接入第三方认证平台实战(登录,注销,当前用户,自定义异常,白名单)_第2张图片
(3)携带token访问资源

通过携带登录获得的token,可以访问到资源。
OAuth2接入第三方认证平台实战(登录,注销,当前用户,自定义异常,白名单)_第3张图片
(4)注销

将当前的token注销掉。
OAuth2接入第三方认证平台实战(登录,注销,当前用户,自定义异常,白名单)_第4张图片
(5)注销后访问资源

注销之后,携带当前的token无法访问资源。
OAuth2接入第三方认证平台实战(登录,注销,当前用户,自定义异常,白名单)_第5张图片
(6)登录后再次访问资源

登录获得新的token,通过新的token可以访问资源。
OAuth2接入第三方认证平台实战(登录,注销,当前用户,自定义异常,白名单)_第6张图片
(7)获取当前用户
查看当前用户信息,包含其拥有的权限
OAuth2接入第三方认证平台实战(登录,注销,当前用户,自定义异常,白名单)_第7张图片

演示完毕,开始讲解

2 授权模式选择

本项目选择密码模式,原因如下, 同一个企业内部的不同产品要使用本企业的 oAuth2.0 体系。在有些情况下,产品希望能够定制化授权页面。由于是同个企业,不需要向用户展示“xxx将获取以下权限”等字样并询问用户的授权意向,而只需进行用户的身份认证即可。这个时候,由具体的产品团队开发定制化的授权界面,接收用户输入账号密码,并直接传递给鉴权服务器进行授权即可。
OAuth2接入第三方认证平台实战(登录,注销,当前用户,自定义异常,白名单)_第8张图片

3 授权服务选择

方案1:认证服务器进行授权管理
方案2:重新定义授权管理器,在资源服务器完成授权

本项目选择的是方案1,方案2也较简单,不过要维护RBAC表。

OAuth2接入第三方认证平台实战(登录,注销,当前用户,自定义异常,白名单)_第9张图片

4 具体操作步骤

4.1 在认证服务数据库中添加客户端信息

配置OAuth2认证允许接入的客户端的信息,因为接入OAuth2认证服务器首先人家得认可你这个客户端。
在表 oauth_client_details 中增加一条客户端配置记录,需要设置的字段如下:

  • client_id:客户端标识
  • client_secret:客户端安全码,此处不能是明文,需要加密
  • scope:客户端授权范围
  • authorized_grant_types:客户端授权类型
  • web_server_redirect_uri:服务器回调地址

在这里插入图片描述

4.2 在认证服务数据库中添加用户及权限信息

认证服务器可以初始化一部分,同时,也可以通过接口调用的方式添加。
OAuth2接入第三方认证平台实战(登录,注销,当前用户,自定义异常,白名单)_第10张图片

4.3 添加依赖(根据自己的spring版本选择对应的版本)

   <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-oauth2</artifactId>
   </dependency>
   <dependency>
       <groupId>org.springframework.security</groupId>
       <artifactId>spring-security-oauth2-resource-server</artifactId
   </dependency>

4.4 添加配置(在application.yaml或application.properties中添加如下配置)

  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: ${auth-server}/getPublicKey

auth-server: http://localhost:8080

security:
  oauth2:
    client:
      client-id: client
      client-secret: secret
      access-token-uri:  ${auth-server}/oauth/token
      user-authorization-uri:  ${auth-server}/oauth/authorize
    resource:
      token-info-uri:  ${auth-server}/oauth/check_token
      user-info-uri: ${auth-server}/me
    sso:
      login-path: /login

附:默认的端点 URL

  • /oauth/authorize:授权端点
  • /oauth/token:令牌端点
  • /oauth/confirm_access:用户确认授权提交端点
  • /oauth/error:授权服务错误信息端点
  • /oauth/check_token:用于资源服务访问的令牌解析端点
  • /oauth/token_key:提供公有密匙的端点,如果你使用JWT 令牌的话

4.5 配置资源服务器

创建一个类继承 ResourceServerConfigurerAdapter 并添加相关注解:
@Configuration
@EnableResourceServer:资源服务器
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true):全局方法拦截

@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {

    @Resource
    private WhiteListConfig whiteListConfig;

    @Override
    public void configure(HttpSecurity http) throws Exception {

        http.oauth2ResourceServer().jwt()
                .jwtAuthenticationConverter(jwtAuthenticationConverter());
        http.exceptionHandling()
                .accessDeniedHandler(accessDeniedHandler())
                .authenticationEntryPoint(authenticationEntryPoint())
                .and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                .antMatchers(ArrayUtil.toArray(whiteListConfig.getUrls(), String.class)).permitAll()
                .anyRequest().authenticated();
    }
    /**
     * 未授权
     *
     * @return
     */
    @Bean
    AccessDeniedHandler accessDeniedHandler() {
        return (request, response, e) -> {
            WebUtils.writeFailedToResponse(response, ResultCode.ACCESS_UNAUTHORIZED);
        };
    }
    /**
     * token无效或者已过期自定义响应
     */
    @Bean
    AuthenticationEntryPoint authenticationEntryPoint() {
        return (request, response, e) -> {
            WebUtils.writeFailedToResponse(response, ResultCode.TOKEN_INVALID_OR_EXPIRED);
        };
    }
    /***
     * 定义JJwtAccessTokenConverter
     * @return
     */
    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
        jwtGrantedAuthoritiesConverter.setAuthorityPrefix(AuthConstants.AUTHORITY_PREFIX);
        jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName(AuthConstants.JWT_AUTHORITIES_KEY);

        JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);
        return jwtAuthenticationConverter;
    }

}

4.5.1 自定义异常

当发生异常的时候,可返回自定义的消息体。


    /**
     * 未授权
     *
     * @return
     */
    @Bean
    AccessDeniedHandler accessDeniedHandler() {
        return (request, response, e) -> {
            WebUtils.writeFailedToResponse(response, ResultCode.ACCESS_UNAUTHORIZED);
        };
    }
    /**
     * token无效或者已过期自定义响应
     */
    @Bean
    AuthenticationEntryPoint authenticationEntryPoint() {
        return (request, response, e) -> {
            WebUtils.writeFailedToResponse(response, ResultCode.TOKEN_INVALID_OR_EXPIRED);
        };
    }

4.5.2 白名单
对于登录和登出的接口,直接放行。
WhiteListConfig:

/**
* 白名单配置
*/
@Data
@Configuration
@ConfigurationProperties(prefix = "whitelist")
public class WhiteListConfig {
    private List<String> urls;
}

application.yml:

whitelist:
  urls:
    - "/api/login"
    - "/api/logout"

4.5.3 token转换
把jwt的Claim中的authorities加入,这样就可以重新定义权限管理器了。

    /***
     * 定义JJwtAccessTokenConverter
     * @return
     */
    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
        jwtGrantedAuthoritiesConverter.setAuthorityPrefix(AuthConstants.AUTHORITY_PREFIX);
        jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName(AuthConstants.JWT_AUTHORITIES_KEY);

        JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);
        return jwtAuthenticationConverter;
    }

4.6 登录及注销

登录就是携带用户名,密码访问${auth-server}/oauth/token,获取token。

/**
* 登录
*
* @return
*/
@PostMapping("/login")
public String login(@RequestBody PlatUserLoginParam loginParam) throws Exception {

    Map<String, Object> params = new HashMap<String, Object>();
    Map<String, Object> header = new HashMap<String, Object>();
    String clientId = oAuth2Properties.getClientId();
    String clientSecret = oAuth2Properties.getClientSecret();
    byte[] bytes = (clientId + ":" + clientSecret).getBytes("utf-8");
    String encode = new BASE64Encoder().encode(bytes);

    header.put(AuthConstants.JWT_TOKEN_HEADER, "Basic " + encode);

    String response = HttpClientUtils.httpPostRequest(oAuth2Properties.getAccessTokenUri() + "?grant_type=" + loginParam.getGrantType()
            + "&username=" + loginParam.getUserName() + "&password=" + loginParam.getPassword(), header, params, "utf-8");

    JSONObject jsonObject = JSONUtil.parseObj(response);
    String accessToken = null;
    if (jsonObject != null) {
        accessToken = jsonObject.getStr("access_token");
    }
    return accessToken;
}

注销
以下就JWT在某些场景需要失效的简单方案整理如下:

  1. 白名单方式
    认证通过时,把JWT缓存到Redis,注销时,从缓存移除JWT。请求资源添加判断JWT在缓存中是否存在,不存在拒绝访问。这种方式和cookie/session机制中的会话失效删除session基本一致。
  2. 黑名单方式
    注销登录时,缓存JWT至Redis,且缓存有效时间设置为JWT的有效期,请求资源时判断是否存在缓存的黑名单中,存在则拒绝访问。
    白名单和黑名单的实现逻辑差不多,黑名单不需每次登录都将JWT缓存,仅仅在某些特殊场景下需要缓存JWT,给服务器带来的压力要远远小于白名单的方式。
    本项目选择黑名单方式实现
/**
* 注销
*
* @return
*/
@DeleteMapping("/logout")
public Result logout() {
    JSONObject jsonObject = WebUtils.getJwtPayload();
    String jti = jsonObject.getStr("jti"); // JWT唯一标识
    long exp = jsonObject.getLong("exp"); // JWT过期时间戳
    long currentTimeSeconds = System.currentTimeMillis() / 1000;
    if (exp < currentTimeSeconds) { // token已过期,无需加入黑名单
        return Result.success();
    }
    redisTemplate.opsForValue().set(AuthConstants.TOKEN_BLACKLIST_PREFIX + jti, null, (exp - currentTimeSeconds), TimeUnit.SECONDS);
    return Result.success();
}

4.7 网关全局过滤器

从请求头提取JWT,解析出唯一标识jti,然后判断该标识是否存在黑名单列表里,如果是直接返回响应token失效的提示信息。

/**
* 全局过滤器
*/
@Component
@Slf4j
public class AuthFilter extends ZuulFilter {


    @Autowired
    private RedisTemplate redisTemplate;
    /**
     * 拦截器执行顺序
     *
     * @return
     */
    @Override
    public int filterOrder() {
        return FORM_BODY_WRAPPER_FILTER_ORDER - 1;
    }
    /**
     * 拦截器类型
     *
     * @return
     */
    @Override
    public String filterType() {
        return FilterConstants.PRE_TYPE;
    }
    @Override
    public boolean shouldFilter() {
        return true;
    }


    @Override
    public Object run() throws ZuulException {
        RequestContext ctx = RequestContext.getCurrentContext();
        HttpServletRequest request = ctx.getRequest();
        HttpServletResponse response = ctx.getResponse();
        // 无token放行
        String token = request.getHeader(AuthConstants.JWT_TOKEN_HEADER);
        if (StrUtil.isBlank(token)) {
            return null;
        }
        // 解析JWT获取jti,以jti为key判断redis的黑名单列表是否存在,存在拦截响应token失效
        token = token.replace(AuthConstants.JWT_TOKEN_PREFIX, Strings.EMPTY);
        JWSObject jwsObject = null;
        try {
            jwsObject = JWSObject.parse(token);
        } catch (ParseException e) {
            e.printStackTrace();
        }
        String payload = jwsObject.getPayload().toString();
        JSONObject jsonObject = JSONUtil.parseObj(payload);
        String jti = jsonObject.getStr("jti");
        Boolean isBlack = redisTemplate.hasKey(AuthConstants.TOKEN_BLACKLIST_PREFIX + jti);
        if (isBlack) {
            ctx.setSendZuulResponse(false);
            ctx.setResponseStatusCode(HttpStatus.UNAUTHORIZED.value());
            ctx.setResponseBody(
                    JSON.toJSONString(CloudwalkResult.fail(GatewayCommonRespCodeEnum.RESPONSE_SERVICE_NOT_EXIST.getCode(),
                            GatewayCommonRespCodeEnum.RESPONSE_SERVICE_NOT_EXIST.getMessage())));
            ctx.getResponse().setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
            return null;
        }
        // 存在token且不是黑名单,request写入JWT的载体信息
        ctx.addZuulRequestHeader(AuthConstants.JWT_PAYLOAD_KEY, payload);
        return null;
    }
}

4.8 获取当前用户信息

 /**
     * 获取当前用户信息
     *
     * @return
     */

    @PostMapping("/me")
    public Result getCurrentUser() throws Exception {

        Map<String, Object> params = new HashMap<String, Object>();
        Map<String, Object> header = new HashMap<String, Object>();
        HttpServletRequest request = getHttpServletRequest();
        String token = request.getHeader(AuthConstants.JWT_TOKEN_HEADER);

        header.put(AuthConstants.JWT_TOKEN_HEADER, token);
        String response = HttpClientUtils.httpGetRequest(resourceServerProperties.getUserInfoUri(), header, params, "utf-8");

        JSONObject jsonObject = JSONUtil.parseObj(response);
        return Result.success(jsonObject);
    }

4.9 令牌刷新

理论背景: 在微服务项目 OAuth2实现微服务的统一认证的背景下,前端调用/oauth/token接口认证,在认证成功会返回两个令牌access_token和refresh_token,出于安全考虑access_token时效相较refresh_token短很多(access_token默认12小时,refresh_token默认30天)。当access_token过期或者将要过期时,需要拿refresh_token去刷新获取新的access_token返回给客户端,但是为了客户良好的体验需要做到无感知刷新。
方案一:浏览器起一个定时轮询任务,每次在access_token过期之前刷新。
方案二:请求时返回access_token过期的异常时,浏览器发出一次使用refresh_token换取access_token的请求,获取到新的access_token之后,重试因access_token过期而失败的请求。
方案比较:
第一种方案实现简单,但在access_token过期之前刷新,那些旧access_token依然能够有效访问,如果使用黑名单的方式限制这些就的access_token无疑是在浪费资源。
第二种方案是在access_token已经失效的情况下才去刷新便不会有上面的问题,但是它会多出来一次请求,而且实现起来考虑的问题相较下比较多,例如在token刷新阶段后面来的请求如何处理,等获取到新的access_token之后怎么重新重试这些请求。
总结:第一种方案实现简单;第二种方案更为严谨,过期续期不会造成已被刷掉的access_token还有效;总之两者都是可行方案,本项目采用第二种方案。
后端
后端部分这里唯一工作是在网关youlai-gateway鉴定access_token过期时抛出一个自定义异常提供给前端判定,如下图所示:
OAuth2接入第三方认证平台实战(登录,注销,当前用户,自定义异常,白名单)_第11张图片
前端
1. OAuth2客户端设置
设置OAuth2客户端支持刷新模式,只有这样才能使用refresh_token刷新换取新的access_token。以及为了方便我们测试分别设置access_token和refresh_token的过期时间,因为默认的12小时和30天我们吃不消的;除此之外,还必须满足t(refresh_token) > 60s + t(access_token)的条件, refresh_token的时效大于access_token时效我们可以理解,那这个60s是怎么回事,别急还是先看实现,原因下文会说明。

{
	"clientId": "youlai-mall-weapp",
	"clientSecret": "123456",
	"resourceIds": "",
	"scope": "all",
	"authorizedGrantTypes": "authorization_code,password,refresh_token,implicit",
	"webServerRedirectUri": null,
	"authorities": null,
	"accessTokenValidity": 3600,
	"refreshTokenValidity": 7200,
	"additionalInformation": null,
	"autoapprove": "true"
}

2 添加刷新令牌方法
设置了支持客户端刷新模式之后,在前端添加一个refreshToken方法,调用的接口和登录认证是同一个接口/oauth/token,只是参数授权方式grant_type的值由password切换到refresh_token,即密码模式切换到刷新模式,这个方法作用是在刷新token之后将新的token写入到localStorage覆盖旧的token。
OAuth2接入第三方认证平台实战(登录,注销,当前用户,自定义异常,白名单)_第12张图片
3 请求响应拦截添加令牌过期处理
在判断响应结果是token过期时,执行刷新令牌方法覆盖本地的token。
在刷新期间需做到两点,一是避免重复刷新,二是请求重试,为了满足以上两点添加了两个关键变量:

refreshing----刷新标识

在第一次access_token过期请求失败时,调用刷新token请求时开启此标识,标识当前正在刷新中,避免后续请求因token失效重复刷新。

waitQueue----请求等待队列

当执行刷新token期间时,需要把后来的请求先缓存到等待队列,在刷新token成功时,重新执行等待队列的请求即可。

let refreshing = false,// 正在刷新标识,避免重复刷新
  waitQueue = [] // 请求等待队列

service.interceptors.response.use(
  response => {
    const {code, msg, data} = response.data
    if (code !== '00000') {
      if (code === 'A0230') { // access_token过期 使用refresh_token刷新换取access_token
        const config = response.config
        if (refreshing == false) {
          refreshing = true
          const refreshToken = getRefreshToken()
          return store.dispatch('user/refreshToken', refreshToken).then((token) => {
            config.headers['Authorization'] = 'Bearer ' + token
            config.baseURL = '' // 请求重试时,url已包含baseURL
            waitQueue.forEach(callback => callback(token)) // 已刷新token,所有队列中的请求重试
            waitQueue = []
            return service(config)
          }).catch(() => { // refresh_token也过期,直接跳转登录页面重新登录
            MessageBox.confirm('当前页面已失效,请重新登录', '确认退出', {
              confirmButtonText: '重新登录',
              cancelButtonText: '取消',
              type: 'warning'
            }).then(() => {
              store.dispatch('user/resetToken').then(() => {
                location.reload()
              })
            })
          }).finally(() => {
            refreshing = false
          })
        } else {
          // 正在刷新token,返回未执行resolve的Promise,刷新token执行回调
          return new Promise((resolve => {
            waitQueue.push((token) => {
              config.headers['Authorization'] = 'Bearer ' + token
              config.baseURL = '' // 请求重试时,url已包含baseURL
              resolve(service(config))
            })
          }))
        }
      } else {
        Message({
          message: msg || '系统出错',
          type: 'error',
          duration: 5 * 1000
        })
      }
    }
    return {code, msg, data}
  },
  error => {
    return Promise.reject(error)
  }
)

以上,第三方认证平台接入完成。

你可能感兴趣的:(Spring实战)