oAuth2认证与授权全流程

1 授权模式的选择

本项目选择密码模式,原因如下, 同一个企业内部的不同产品要使用本企业的 oAuth2.0 体系。在有些情况下,产品希望能够定制化授权页面。由于是同个企业,不需要向用户展示“xxx将获取以下权限”等字样并询问用户的授权意向,而只需进行用户的身份认证即可。这个时候,由具体的产品团队开发定制化的授权界面,接收用户输入账号密码,并直接传递给鉴权服务器进行授权即可。
在这里插入图片描述

2 资源服务器

网关这里是担任资源服务器的角色,因为网关是微服务资源访问的统一入口,所以在这里做资源访问的统一鉴权是再合适不过。

2.1 pom依赖

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

2.2 配置文件

spring
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: ${
    
    auth-server}/api/v1/oidc/jwk
# oAuth2配置
auth-server: http://10.128.34.17:8858/sso


security:
  oauth2:
    client:
      user-authorization-uri: ${
    
    auth-server}/oauth/authorize
      access-token-uri: ${
    
    auth-server}/oauth/token
      user-info-uri: ${
    
    auth-server}/api/v1/user/info
    resource:
      jwt:
        key-uri: ${
    
    auth-server}/oauth/token_key
      user-api-uri: ${
    
    auth-server}/api/v1/resource/res/user/api/query
  sso:
    login-path: /login

2.3 鉴权管理器

鉴权管理器是作为资源服务器验证是否有权访问资源的裁决者,核心部分的功能先已通过注释形式进行说明,后面再对具体形式补充。

@Component
public class CustomAuthorizationManager implements AccessDecisionManager {
    
    

    protected final Logger logger = LoggerFactory.getLogger(this.getClass());

    @Autowired
    UserResourceService userResourceService;

    @Override
    public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> collection) throws AccessDeniedException, InsufficientAuthenticationException {
    
    

        AntPathMatcher pathMatcher = new AntPathMatcher();
        pathMatcher.setCaseSensitive(false); // 忽略大小写

        //1 获取当前请求路径
        String path = ((FilterInvocation) object).getRequestUrl();

        //2 忽略url请放在此处进行过滤放行
        if (path.contains("login") || path.contains("captcha") || path.contains("logout")
        ||path.contains("refreshToken")) {
    
    
            return;
        }
        //3 token为空拒绝访问
        String token = OAuth2Utils.getToken();
        if (StrUtil.isBlank(token)) {
    
    
            throw new BadCredentialsException(AuthRespCodeEnum.RESPONSE_50001201.getMessage());
        }

        if (authentication.isAuthenticated()) {
    
    
            //4 查询当前用户拥有的API权限
            if (userResourceService == null) {
    
    
                userResourceService = (UserResourceService) SpringContextHolder.getBean("userResourceService");
            }
            UserApiQueryParam queryParam = new UserApiQueryParam();
            List<UserApi> userApis = userResourceService.getUserApi(queryParam);
            //模拟
            UserApi api = new UserApi();
            api.setUri("/stars/device/query");
            userApis = new ArrayList<>();
            userApis.add(api);

            if (!CollectionUtils.isEmpty(userApis)) {
    
    
                for (UserApi userApi : userApis) {
    
    
                    if (pathMatcher.match(userApi.getUri(), path)) {
    
    
                        return;
                    }
                }
            }
        }

        throw new AccessDeniedException(AuthRespCodeEnum.RESPONSE_50001221.getMessage());
    }

    @Override
    public boolean supports(ConfigAttribute configAttribute) {
    
    
        return true;
    }

    @Override
    public boolean supports(Class<?> aClass) {
    
    
        return true;
    }
}

第1、2、3处只是做些基础访问判断,不做过多的说明。

第4处从认证服务器获取资源权限数据,根据请求路径去匹配resourceRolesMap的资url(Ant Path匹配规则)。注意: 这里我们可以自定义一套RBAC关系来做限权。

从认证平台获取用户权限如下:
在这里插入图片描述在这里插入图片描述

2.4 资源服务器配置

这里做的工作是将鉴权管理器AuthorizationManager配置到资源服务器、请求白名单放行、无权访问和无效token的自定义异常响应。配置类基本上都是约定俗成那一套,核心功能和注意的细节点通过注释说明。

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

    @Resource
    private WhiteListConfig whiteListConfig;

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

        http.oauth2ResourceServer().jwt()
                .jwtAuthenticationConverter(jwtAuthenticationConverter());

        http.exceptionHandling()
                .accessDeniedHandler(new CustomAccessDeniedHandler())
                .authenticationEntryPoint(new CustomAuthExceptionEntryPoint())
                .and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                .antMatchers(whiteListConfig.getUrls() == null ? new String[]{
    
    "/login"} : ArrayUtil.toArray(whiteListConfig.getUrls(), String.class)).permitAll()
                .anyRequest().authenticated()
                .accessDecisionManager(new CustomAuthorizationManager())
                .and().csrf().disable();
    }


    @Override
    public void configure(ResourceServerSecurityConfigurer resource) {
    
    

        //自定义异常加进去
        resource.authenticationEntryPoint(new CustomAuthExceptionEntryPoint())
                .accessDeniedHandler(new CustomAccessDeniedHandler());
    }

    /***
     * 定义JwtAccessTokenConverter
     * @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;
    }

}

2.5 自定义异常

用户认证和授权按自定义格式返回异常。

2.5.1 CustomAccessDeniedHandler

@Component("customAccessDeniedHandler")
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
    
    

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
                       AccessDeniedException accessDeniedException)
            throws IOException, ServletException {
    
    
        response.setContentType("application/json;charset=UTF-8");
        Map<String, Object> map = new HashMap<String, Object>();
        map.put("code", AuthRespCodeEnum.RESPONSE_50001221.getCode());//401
        map.put("message", AuthRespCodeEnum.RESPONSE_50001221.getMessage());
        map.put("success", false);
        ObjectMapper mapper = new ObjectMapper();
        response.setContentType("application/json");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.getWriter().write(mapper.writeValueAsString(map));
    }
}

2.5.2 CustomAccessDeniedHandler

@Component("customAuthExceptionEntryPoint")
public class CustomAuthExceptionEntryPoint implements AuthenticationEntryPoint {
    
    

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException) throws ServletException {
    
    
        Map<String, Object> map = new HashMap<String, Object>();
        Throwable cause = authException.getCause();
        if (cause instanceof InvalidTokenException) {
    
    
            map.put("code", AuthRespCodeEnum.RESPONSE_50001224.getCode());//401
            map.put("message", AuthRespCodeEnum.RESPONSE_50001224.getMessage());
        } else {
    
    
            map.put("code", AuthRespCodeEnum.RESPONSE_50001201.getCode());//401
            map.put("message", AuthRespCodeEnum.RESPONSE_50001201.getMessage());
        }
        map.put("success", false);
        response.setContentType("application/json");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        try {
    
    
            ObjectMapper mapper = new ObjectMapper();
            mapper.writeValue(response.getOutputStream(), map);
        } catch (Exception e) {
    
    
            throw new ServletException();
        }
    }
}

3 注销使JWT失效

JWT最大的一个优势在于它是无状态的,自身包含了认证鉴权所需要的所有信息,服务器端无需对其存储,从而给服务器减少了存储开销。但是无状态引出的问题也是可想而知的,它无法作废未过期的JWT,只能由手动使token作废了。我们可以采取黑名单方式。

3.1 黑名单方式

注销登录时,缓存JWT至Redis,且缓存有效时间设置为JWT的有效期,请求资源时判断是否存在缓存的黑名单中,存在则拒绝访问。

3.2 黑名单方式实现

3.2.1 星辰服务退出登录接口

登出接口/stars/account/logout的主要逻辑把JWT添加至Redis黑名单缓存中,但没必要把整个JWT字符串都存储下来,JWT的载体中有个jti(JWT ID)字段声明为JWT提供了唯一的标识符。JWT解析的结构如下:

    /**
     * 退出登录
     *
     * @return 退出登录结果
     */
    @Override
    @Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class)
    public CloudwalkResult<Boolean> logout() throws ServiceException {
    
    
        try {
    
    
            String accessToken = WebUtils.getToken();
            CloudwalkResult result = null;
            if (StringUtils.isNotEmpty(accessToken)) {
    
    
                // 查询用户信息
                Map<String, Object> params = new HashMap<String, Object>();
                Map<String, Object> header = new HashMap<String, Object>();

                header.put(AuthConstants.JWT_TOKEN_HEADER, accessToken);

                String resp = HttpClientUtils.httpPostRequest(oAuth2Properties.getUserLogoutUri(), header, params, "utf-8");
                if (StringUtils.isNotEmpty(resp)) {
    
    
                    result = JsonUtils.toObj(resp, CloudwalkResult.class);
                    if (result != null && result.isSuccess()) {
    
    
                        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 CloudwalkResult.success(true);
                        }
                        redisManager.set(AuthConstants.TOKEN_BLACKLIST_PREFIX + jti, null, (exp - currentTimeSeconds));
                        return CloudwalkResult.success(true);
                    }
                }
            }
            return CloudwalkResult.fail(AuthRespCodeEnum.RESPONSE_50001223.getCode(), AuthRespCodeEnum.RESPONSE_50001223.getMessage());
        } catch (Exception e) {
    
    
            logger.error("用户登出失败,原因:" + e);
            throw new ServiceException(e);
        }
    }

3.2.2 网关的全局过滤器

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


@Component
public class UserFilter extends ZuulFilter {
    
    

    protected final Logger logger = LoggerFactory.getLogger(this.getClass());


    @Autowired
    private RedisTemplate redisTemplate;

    @Override
    public String filterType() {
    
    
        return FilterConstants.PRE_TYPE;
    }

    @Override
    public int filterOrder() {
    
    
        return FORM_BODY_WRAPPER_FILTER_ORDER - 1;
    }

    @Override
    public boolean shouldFilter() {
    
    
        return true;
    }

    @Override
    public Object run() throws ZuulException {
    
    

        return this.loginValidate();
    }

    private Object loginValidate() {
    
    
        RequestContext ctx = RequestContext.getCurrentContext();
        // 先判断路径,如果是登录不需要校验
        String url = ctx.getRequest().getRequestURI();
        Boolean flag = Pattern.matches(".*login|.*token|.*captcha|.*logout", url);
        logger.info("是否为登录或获取验证码:" + flag);
        if (flag) {
    
    
            return null;
        }
        String token = OAuth2Utils.getToken();

        logger.info("请求中的token为:" + token);

        if (StringUtils.isNotBlank(token)) {
    
    
            try {
    
    
                // 解析JWT获取jti,以jti为key判断redis的黑名单列表是否存在,存在拦截响应token失效
                token = token.replace(AuthConstants.JWT_TOKEN_PREFIX, Strings.EMPTY);
                JWSObject jwsObject = null;

                jwsObject = JWSObject.parse(token);

                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(AuthRespCodeEnum.RESPONSE_50001201.getCode(),
                                    AuthRespCodeEnum.RESPONSE_50001201.getMessage())));
                    ctx.getResponse().setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
                    return null;
                }
                // 存在token且不是黑名单,request写入JWT的载体信息
                ctx.addZuulRequestHeader(AuthConstants.JWT_PAYLOAD_KEY, payload);
            } catch (Exception e) {
    
    
                logger.info("token校验异常:" + e + ":" + token);
                ctx.setSendZuulResponse(false);
                ctx.setResponseStatusCode(401);
                ctx.setResponseBody(JsonUtils.toJson(CloudwalkResult.fail(GatewayCommonRespCodeEnum.RESPONSE_SYSTEM_ERROR.getCode(),
                        GatewayCommonRespCodeEnum.RESPONSE_SYSTEM_ERROR.getMessage())));
                ctx.getResponse().setContentType("application/json;charset=UTF-8");
                return null;
            }
            return null;
        }
        ctx.setSendZuulResponse(false);
        ctx.setResponseStatusCode(401);
        ctx.setResponseBody(JsonUtils.toJson(CloudwalkResult.fail(AuthRespCodeEnum.RESPONSE_50001201.getCode(),
                AuthRespCodeEnum.RESPONSE_50001201.getMessage())));
        ctx.getResponse().setContentType("application/json;charset=UTF-8");
        return null;
    }
}

4 刷新token

刷新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还有效;总之两者都是可行方案,我更倾向于第二种,下面介绍第二种方式的实现。

4.1 实现

前后端配合利用双token刷新实现JWT续期的功能需求,后端抛出token过期异常,前端捕获之后调用刷新token请求,成功则完成续期,失败(一般指refresh_token也过期了)则需要重新登录。

4.1.1 后端

后端部分这里工作是在网关cloudwalk-server-gateway鉴定access_token过期时抛出一个自定义异常提供给前端判定,如下图所示:

在这里插入图片描述
星辰服务提供一个刷新token的接口:

    @Override
    @Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class)
    public CloudwalkResult<CwArRefreshTokenResult> refreshToken(CwArRefreshTokenParam param) throws ServiceException {
    
    

        try {
    
    
            Map<String, Object> params = new HashMap<String, Object>();
            Map<String, Object> header = new HashMap<String, Object>();

            header.put(AuthConstants.JWT_TOKEN_HEADER, AuthConstants.CLIENT_BASIC_PREFIX + getClientBasic());

            String resp = HttpClientUtils.httpPostRequest(oAuth2Properties.getAccessTokenUri() + "?grant_type=" + AuthorizationGrantEnum.REFRESH.getGrantType()
                            + "&refresh_token=" + param.getRefreshToken(),
                    header, params, "utf-8");
            if (StringUtils.isNotEmpty(resp)) {
    
    
                CwArRefreshTokenResult result = JsonUtils.toObj(resp, CwArRefreshTokenResult.class);
                if (result != null && StringUtils.isNotEmpty(result.getAccess_token())) {
    
    
                    return CloudwalkResult.success(result);
                }
            }
            return CloudwalkResult.fail(AuthRespCodeEnum.RESPONSE_REFRESH_TOKEN_FAIL.getCode(), AuthRespCodeEnum.RESPONSE_REFRESH_TOKEN_FAIL.getMessage());
        } catch (Exception e) {
    
    
            logger.info("刷新token异常,无效", e);
            throw new ServiceException(cn.cloudwalk.client.account.common.constant.RespCodeConstant.WRONG_TOKEN, this.getMessage(cn.cloudwalk.client.account.common.constant.RespCodeConstant.WRONG_TOKEN));
        }
    }

4.1.2 前端

请求响应拦截添加令牌过期处理
在判断响应结果是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)
  }
)

5 白名单

5.1 配置文件

# 配置白名单路径(无需登录)
whitelist:
  urls:
    - "/stars/account/login/**"
    - "/stars/account/captcha/**"
    - "/stars/stars/account/refreshToken"

5.2 ResourceServerConfig配置

在这里插入图片描述

6 演示

6.1 不携带token直接访问资源

在这里插入图片描述

6.2 携带假的token直接访问资源

在这里插入图片描述

6.3 用户不输入验证码登录

在这里插入图片描述

6.4 获取验证码

在这里插入图片描述

6.5 携带验证码,但是用户名或密码错误

在这里插入图片描述

6.6 携带验证码,且用户名和密码正确

在这里插入图片描述

6.7 携带登录token,访问资源

在这里插入图片描述

6.8 携带登录token,访问没有权限的资源

在这里插入图片描述

6.9 用户登出之后,再访问资源

在这里插入图片描述
在这里插入图片描述

6.10 刷新token

在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/A1342772/article/details/115047996