关于 SpringCloud oauth2 JWT 认证与授权解决用户信息变更token失效问题的解决(伪)

苍宝
2023-12-01

oauth2不是什么简单的东西,本文不适用于从零搭建oauth2环境。

网上会告诉你的解决方式

将token存入redis,每次访问验证redis里的token对应的用户信息。判断是否密码变更,账号是否过期等信息。

这是我在网上能找到的的关于jwt的教程中看到最为广泛的一种做法,个人非常抗拒这种做法。

回顾一下授权与认证方式的演变过程,从我接触Java开始。

我们使用最原始的就是session模式,有状态模式,利用客户端与服务端的session缓存来达到辨别用户的目的。时至今日,我们仍然可以看到一些项目在使用。

之后是redis时代,有状态模式(为了解决分布式session共享应景而生的一种手段),利用redis存放session来达到不同实例之间的session共享。

最后是令牌模式,无状态模式(中间经历了多少其实我并不清楚,因为我其实除了学习阶段,上手便是JWT),也就是token模式,token模式的出现又为了解决什么样的问题呢。答案是服务器压力,用户量日益增加的web服务如果每建立一个会话就在服务端存放一条session缓存,无疑对服务器的压力是巨大的。而且session也不并不安全,当然这不是本文讨论的内容。

JWT无法解决的痛点,即,token颁发后则脱离控制,无法使之失效的问题。

再来说说开篇为什么说个人抗拒使用redis来存放token的做法。其实已经显而易见了。之所以使用jwt,就是因为要解决服务器存放session带来的巨大服务器压力。这倒好,好不容易从有状态进化到无状态模式,一个redis,又给整回去了!

记住一件事情,使用Token模式(严谨的讲,是基于JWT的Token模式,实际上springcloud oauth2 原生token就是个session_id,仅此而已),首先第一点,禁用任何形式的session,否则,请用回session模式

来说说我的想法

题外话:单点登录也不要使用Nginx来把服务都转发到统一域名下,然后共享Cookies来实现。可不可行?可行,但是非常的low。因为如果把Nginx看成一个整体的服务。那么这种做法无疑是单点了个寂寞(下次有时间再深入讨论这个问题)。

我们都知道,oauth2认证模式,授权码模式,隐藏模式,密码模式,客户端模式,此外还有一种,刷新令牌模式,这并非单独的一种,而是以上4中都可以配合刷新token模式来使用。

而我们这里讲最严谨的授权码模式,采用自动授权方式(即:登陆后不用手动点击授权)。

思路:

已知:刷新令牌(refresh token)的作用,在token过期后,可以在不登陆的情况下使用刷新token到认证服务器获取新的token而无需再次登录。

得知:以短到令人发指的token失效时间来保证已颁发的令牌总是失效(5分钟,甚至更短)。在token失效时使用隐蔽的方式重新获得新的token,就可以达到一种修改密码之后,原来的token会在5分钟甚至更短时间之内失效。而如果没有密码变更,则一直可以访问。但是token5分钟变化一次用户却感知不到的一种效果。用户无感知,密码改了,token失效了。不就是我们想达到的效果吗。尽管它并不是实时的,但是至少看上去是像的。

理论可行,实践开始

认证与授权服务器关键代码(本文非小白文,如果从搭建开始讲起,那得写本书)

客户端的配置

// 客户端配置
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
    clients.inMemory()
            .withClient("client_name")
            .secret(passwordEncoder.encode("client_secret"))
            // 授权码模式,刷新token模式
            .authorizedGrantTypes("authorization_code", "refresh_token")
            .scopes("scopename")
            // 是否自动授权
            .autoApprove(true)
            /*
             * 可颁发授权码的服务地址,这里是瞎写的,正常开发环境应从数据库读取
             * 这里应该是一个可以处理授权码的一个页面,记住是页面而非接口
             * 任何授权相关的内容皆与资源服务器无关,只跟授权服务器做交互
             * */
            .redirectUris("http://localhost/token");
}

token颁发时的处理与刷新token时的处理

import com.hrx.entity.AuthUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.CredentialsExpiredException;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.oauth2.common.DefaultOAuth2AccessToken;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import java.util.LinkedHashMap;
import java.util.Map;

public class MetroJwtTokenConverter extends JwtAccessTokenConverter {

    @Autowired
    UserDetailsService userDetailsService;

    @Override
    public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
        AuthUser user;
        // 这个判断可以判断是不是来刷新token的,AuthUser是我们自己的用户实体类。它应实现自UserDetails接口
        if(authentication.getPrincipal() instanceof AuthUser) {
            // 不是来刷新token的。可以这样拿到用户信息。
            user = (AuthUser) authentication.getPrincipal();
        }else {
            // 是刷新token的,authentication.getPrincipal()只能获取到用户名,首先拿到刷新token
            String tokenValue = authentication.getOAuth2Request().getRefreshTokenRequest().getRequestParameters().get("refresh_token");
            // 从token中获取信息
            Map<String, Object> decode = this.decode(tokenValue);
            // authentication.getPrincipal()只能拿到用户名, 这里是关键代码,从数据库获取该用户,比对密码。当然这个密码是加密过的
            user = (AuthUser) userDetailsService.loadUserByUsername(authentication.getPrincipal().toString());
            // 已发生修改或者已被封禁的逻辑都可以在这里判断,然后抛出这个异常。即:401异常
            if(!user.getPassword().equals(decode.get("verification"))) {
                throw new CredentialsExpiredException("密码已在其它地方修改, 登录失效");
            }
        }
        // 我们要往token中存信息
        Map<String, Object> additionalInformation = new LinkedHashMap<>();
        additionalInformation.put("company_id", user.getCompanyNum());
        additionalInformation.put("step", user.getStep());
        /*
         * 这里是关键的地方,其实就是用户密码。当然是加密后的。这里要存
         * 上面才能获取到。这里会在颁发token的时候被执行到。所以token中会存在这些信息
         */
        additionalInformation.put("verification", user.getPassword());
        ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInformation);
        return super.enhance(accessToken, authentication);
    }
}

更短的token失效时间,与更长的刷新token失效时间

// 令牌访问端点与令牌服务
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
        endpoints.authenticationManager(authenticationManager)
//                .pathMapping()// 映射自定义访问路径
                .userDetailsService(userDetailsService)
                .authorizationCodeServices(authorizationCodeServices())
//                .tokenStore(tokenStore)
//                .tokenEnhancer(jwtAccessTokenConverter)
                .tokenServices(tokenService(endpoints))
                .allowedTokenEndpointRequestMethods(HttpMethod.POST)
                .pathMapping("/oauth/confirm_access", "/confirm_access");
    }

    public AuthorizationCodeServices authorizationCodeServices() {
        return new InMemoryAuthorizationCodeServices();
    }

    private AuthorizationServerTokenServices tokenService(AuthorizationServerEndpointsConfigurer endpoints) {
        DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
        defaultTokenServices.setSupportRefreshToken(true);//允许支持刷新token
        defaultTokenServices.setClientDetailsService(endpoints.getClientDetailsService());
        defaultTokenServices.setTokenEnhancer(endpoints.getTokenEnhancer());
        // 5分钟失效,如果发生密码修改等账户变更,300秒内旧token将失效
        defaultTokenServices.setAccessTokenValiditySeconds(300);
        // 如果没有变更,则这个刷新token可以在2天内获取到新的token
        defaultTokenServices.setRefreshTokenValiditySeconds(2 * 24 * 60 * 60);
        defaultTokenServices.setTokenEnhancer(jwtAccessTokenConverter);
        defaultTokenServices.setTokenStore(tokenStore);
        return defaultTokenServices;
    }

这里也是重头戏,关于vue前端对刷新token的支持,非常优雅的操作

把axios实例的操作都代理一便,把每个请求的then,catch,finally都跟对应的请求ID一起存在vuex里

import service from '@/utils/authorizationRequest'
import store from '@/store'

export default function Request(baseURL, config) {
  // 每一个请求都有自己的ID
  const conf = { ...config, requestId: ++ store.state.app.requestCurrentId }
  // 创建一个请求
  this.request = service(baseURL)(conf)
  // 把请求存起来
  store.state.app.requests[conf.requestId] = {}
  // 代理原本的then操作, 为什么要代理,非常重要,这里传入一个函数
  this.then = function(resolve) {
    // then操作存起来
    store.state.app.requests[conf.requestId].thenFunc = resolve
    // 执行then操作
    this.request.then(resolve)
    // 连着点,它是真的香
    return this
  }

  this.catch = function(reject) {
    // catch操作也存起来
    store.state.app.requests[conf.requestId].catchFunc = reject
    this.request.catch(reject)
    return this
  }

  this.finally = function(fl) {
    // finally操作当然也存起来
    store.state.app.requests[conf.requestId].finallyFunc = fl
    this.request.finally(fl)
    return this
  }
}

拦截返回401的请求(JS真的是烦得很呢,我不知道括号有没有对齐,将就看下吧,反正也是片段,相信看到这里的没有谁是很菜的。应该都能明白我想表达的意思,甚至各位大神可以自行优化,有不对的地方也希望指出来,实现全民进化嘛)

import axios from 'axios'
import store from '@/store'
import refreshToken from '@/api/refreshToken'

// 不要每个401都去刷新一次,第一个401标价为刷新正在进行,则标记为true,刷新完了标记回来
let isRefresh = false
// 401的请求存在这儿 
let requests = []

export default function service(baseURL) {
  // 创建一个axios请求
  const service = axios.create({ baseURL })service.interceptors.response.use(
  response => {
      // 已经成功的请求就不要浪费内存了。记得回收
      store.state.app.requests[response.config.requestId] = undefined
      //请求成功前置处理,省略吧,太多了
      return res.data
  },error => {
if (error.response.status === 401) {
  /*
  * 401的都给它存起来,这些是在token失效之后发送的请求,必定都会401
  * 我们并不希望它就此没了
  * 而是希望我们用了新的token之后,可以接它们们回来
  * 逝去的先辈们终究不是真的逝去
  * */
  requests.push(error.config)
  // 如果还没有开始刷新token,就从这里开始
  if (!isRefresh) {
    // 标记为正在刷新
    isRefresh = true
    console.log('开始刷新token')
    // 这个请求不能从本axios配置获取,它不能有这个刷新token逻辑,因为它自己就是刷新token的
    // 它失败了就是失败了,没救了
    refreshToken()
      .then(res => {
        // 往cookie里写token
        setToken(res.access_token)
        // 遍历失效期间401的请求
        requests.forEach(config => {
          // 给它换个token
          config.headers[TokenKey] = res.access_token
          // 重新创建请求
          service(config)
            .then(res => {
              // 看看vuex里存了这个请求的then操作没
              if (store.state.app.requests[config.requestId].thenFunc) {
                // 有的话再执行一次
                store.state.app.requests[config.requestId].thenFunc(res)
              }
            })
            .catch(res => {
              // 看看catch方法
              if (store.state.app.requests[config.requestId].catchFunc) {
                store.state.app.requests[config.requestId].catchFunc(res)
              }
            })
            .finally(() => {
              // 再看看finally方法
              if (store.state.app.requests[config.requestId].finallyFunc) {
                store.state.app.requests[config.requestId].finallyFunc()
              }
              // 完了之后记得清理store里存放的东西
              store.state.app.requests[config.requestId] = undefined
            })
        })
        // 把存放的请求清理一下
        requests = []
      })
      .catch(err => {
        Message({
          message: '无效令牌',
          type: 'error',
          duration: 5 * 1000
        })
        setTimeout(() => {
          window.location.href = '/'
        }, 2000)
      })
      .finally(() => {
        console.log('刷新token完成, 重置刷新状态')
        isRefresh = false
      })
  }
}

 类似资料: