User-Profile-Image
hankin
  • 5
  • Java
  • Kotlin
  • Spring
  • Web
  • SQL
  • MegaData
  • More
  • Experience
  • Enamiĝu al vi
  • 分类
    • Zuul
    • Zookeeper
    • XML
    • WebSocket
    • Web Notes
    • Web
    • Vue
    • Thymeleaf
    • SQL Server
    • SQL Notes
    • SQL
    • SpringSecurity
    • SpringMVC
    • SpringJPA
    • SpringCloud
    • SpringBoot
    • Spring Notes
    • Spring
    • Servlet
    • Ribbon
    • Redis
    • RabbitMQ
    • Python
    • PostgreSQL
    • OAuth2
    • NOSQL
    • Netty
    • MySQL
    • MyBatis
    • More
    • MinIO
    • MegaData
    • Maven
    • LoadBalancer
    • Kotlin Notes
    • Kotlin
    • Kafka
    • jQuery
    • JavaScript
    • Java Notes
    • Java
    • Hystrix
    • Git
    • Gateway
    • Freemarker
    • Feign
    • Eureka
    • ElasticSearch
    • Docker
    • Consul
    • Ajax
    • ActiveMQ
  • 页面
    • 归档
    • 摘要
    • 杂图
    • 问题随笔
  • 友链
    • Spring Cloud Alibaba
    • Spring Cloud Alibaba - 指南
    • Spring Cloud
    • Nacos
    • Docker
    • ElasticSearch
    • Kotlin中文版
    • Kotlin易百
    • KotlinWeb3
    • KotlinNhooo
    • 前端开源搜索
    • Ktorm ORM
    • Ktorm-KSP
    • Ebean ORM
    • Maven
    • 江南一点雨
    • 江南国际站
    • 设计模式
    • 熊猫大佬
    • java学习
    • kotlin函数查询
    • Istio 服务网格
    • istio
    • Ktor 异步 Web 框架
    • PostGis
    • kuangstudy
    • 源码地图
    • it教程吧
    • Arthas-JVM调优
    • Electron
    • bugstack虫洞栈
    • github大佬宝典
    • Sa-Token
    • 前端技术胖
    • bennyhuo-Kt大佬
    • Rickiyang博客
    • 李大辉大佬博客
    • KOIN
    • SQLDelight
    • Exposed-Kt-ORM
    • Javalin—Web 框架
    • http4k—HTTP包
    • 爱威尔大佬
    • 小土豆
    • 小胖哥安全框架
    • 负雪明烛刷题
    • Kotlin-FP-Arrow
    • Lua参考手册
    • 美团文章
    • Java 全栈知识体系
    • 尼恩架构师学习
    • 现代 JavaScript 教程
    • GO相关文档
    • Go学习导航
    • GoCN社区
    • GO极客兔兔-案例
    • 讯飞星火GPT
    • Hollis博客
    • PostgreSQL德哥
    • 优质博客推荐
    • 半兽人大佬
    • 系列教程
    • PostgreSQL文章
    • 云原生资料库
    • 并发博客大佬
Help?

Please contact us on our email for need any support

Support
    首页   ›   Spring   ›   SpringSecurity   ›   OAuth2   ›   正文
OAuth2

SpringSecurity—OAuth 2(十)OAuth2 结合 JWT

2020-09-14 10:46:36
1279  0 1
参考目录 隐藏
1) 回顾一下:
2) 什么是有状态
3) 什么是无状态
4) 如何实现无状态
5) JWT 存在的问题
6) OAuth2 中的问题
7) 改造方案
8) 授权服务器改造
9) 资源服务器改造
10) 测试
11) 原理
12) jwt 解码

阅读完需:约 15 分钟

传统的 session 来记录用户认证信息的方式我们可以理解为这是一种有状态登录,而 JWT 则代表了一种无状态登录。无状态登录天然的具备单点登录能力,所以这个技术组合小伙伴们还是很有必要认真学习下。

关于 JWT 的相关:

Java—使用Jwt(Token令牌)

Java—JJWT使用

SpringBoot—Spring Security 中使用 JWT ( 无状态登录 )


回顾一下:

什么是有状态

有状态服务,即服务端需要记录每次会话的客户端信息,从而识别客户端身份,根据用户身份进行请求的处理,典型的设计如 Tomcat 中的 Session。例如登录:用户登录后,我们把用户的信息保存在服务端 session 中,并且给用户一个 cookie 值,记录对应的 session,然后下次请求,用户携带 cookie 值来(这一步有浏览器自动完成),我们就能识别到对应 session,从而找到用户的信息。这种方式目前来看最方便,但是也有一些缺陷,如下:

  • 服务端保存大量数据,增加服务端压力
  • 服务端保存用户状态,不支持集群化部署

什么是无状态

微服务集群中的每个服务,对外提供的都使用 RESTful 风格的接口。而 RESTful 风格的一个最重要的规范就是:服务的无状态性,即:

服务端不保存任何客户端请求者信息客户端的每次请求必须具备自描述信息,通过这些信息识别客户端身份

那么这种无状态性有哪些好处呢?

  • 客户端请求不依赖服务端的信息,多次请求不需要必须访问到同一台服务器
  • 服务端的集群和状态对客户端透明
  • 服务端可以任意的迁移和伸缩(可以方便的进行集群化部署)
  • 减小服务端存储压力

如何实现无状态

无状态登录的流程:

  • 首先客户端发送账户名/密码到服务端进行认证
  • 认证通过后,服务端将用户信息加密并且编码成一个 token,返回给客户端
  • 以后客户端每次发送请求,都需要携带认证的 token
  • 服务端对客户端发送来的 token 进行解密,判断是否有效,并且获取用户登录信息

JWT 存在的问题

说了这么多,JWT 也不是天衣无缝,由客户端维护登录状态带来的一些问题在这里依然存在,举例如下:

  • 1. 续签问题,这是被很多人诟病的问题之一,传统的 cookie+session 的方案天然的支持续签,但是jwt 由于服务端不保存用户状态,因此很难完美解决续签问题,如果引入 redis,虽然可以解决问题,但是 jwt 也变得不伦不类了。
  • 2. 注销问题,由于服务端不再保存用户信息,所以一般可以通过修改 secret 来实现注销,服务端secret 修改后,已经颁发的未过期的 token 就会认证失败,进而实现注销,不过毕竟没有传统的注销方便。
  • 3. 密码重置,密码重置后,原本的 token 依然可以访问系统,这时候也需要强制修改 secret。
  • 4. 基于第 2 点和第 3 点,一般建议不同用户取不同 secret。

OAuth2 中的问题

授权服务器派发了 access_token 之后,客户端拿着 access_token 去请求资源服务器,资源服务器要去校验 access_token 的真伪,所以我们在资源服务器上配置了RemoteTokenServices,让资源服务器做远程校验:

    @Bean
    RemoteTokenServices tokenServices(){
        //远程令牌服务
        RemoteTokenServices services=new RemoteTokenServices();
        //设置检查令牌端点网址
        services.setCheckTokenEndpointUrl("http://localhost:8080/oauth/check_token");
        //设定客户编号
        services.setClientId("xjh");
        //设置客户机密
        services.setClientSecret("123");
        return services;
    }

在高并发环境下这样的校验方式显然是有问题的,如果结合 JWT,用户的所有信息都保存在 JWT 中,这样就可以有效的解决上面的问题。


接着前面的文章内容改造,大体的框架还是基于授权码模式:

SpringSecurity—OAuth 2(二)授权码模式


改造方案

授权服务器改造

首先改造的是 auth-server 里面的 AccessTokenConfig 文件:

@Configuration
public class AccessTokenConfig  {
    /**
     * Jwt访问令牌转换器
     * @return
     */
    @Bean
    JwtAccessTokenConverter jwtAccessTokenConverter(){
        JwtAccessTokenConverter jwtAccessTokenConverter=new JwtAccessTokenConverter();
        //设置签名密钥 为力防止其他人篡改
        jwtAccessTokenConverter.setSigningKey("mimao");
        return jwtAccessTokenConverter;
    }

//    @Autowired
//    RedisConnectionFactory redisConnectionFactory;
    @Bean
    TokenStore tokenStore(){
        // 使用jwt为令牌
        return new JwtTokenStore(jwtAccessTokenConverter());
        //在redis里面存储令牌
//        return new RedisTokenStore(redisConnectionFactory);
        //在内存中存储令牌
//        return new InMemoryTokenStore();
    }
}

这里的改造主要是两方面:

  • 1. TokenStore 我们使用 JwtTokenStore 这个实例。之前我们将 access_token 无论是存储在内存中,还是存储在 Redis 中,都是要存下来的,客户端将 access_token 发来之后,我们还要校验看对不对。但是如果使用了 JWT,access_token 实际上就不用存储了(无状态登录,服务端不需要保存信息),因为用户的所有信息都在 jwt 里边,所以这里配置的 JwtTokenStore 本质上并不是做存储。
  • 2. 另外我们还提供了一个 JwtAccessTokenConverter,这个 JwtAccessTokenConverter 可以实现将用户信息和 JWT 进行转换(将用户信息转为 jwt 字符串,或者从 jwt 字符串提取出用户信息)。
  • 3. 另外,在 JWT 字符串生成的时候,我们需要一个签名,这个签名需要自己保存好。

这里 JWT 默认生成的用户信息主要是用户角色、用户名等,如果我们希望在生成的 JWT 上面添加额外的信息,可以按照如下方式:


额外的信息示例:


添加额外信息需要改造的地方是在 auth-server 里面创建一个新的文件MyAdditionalInformation 实现 TokenEnhancer

/**
 * 我的附加信息
 * 令牌增强器
 */
@Component
public class MyAdditionalInformation implements TokenEnhancer {
    @Override
    public OAuth2AccessToken enhance(OAuth2AccessToken oAuth2AccessToken, OAuth2Authentication oAuth2Authentication) {
        //获取其他信息(原本的信息)
        Map<String, Object> map = oAuth2AccessToken.getAdditionalInformation();
        //继续添加新的信息
        map.put("site","www.enmalvi.com");
        //设置附加信息
        ((DefaultOAuth2AccessToken) oAuth2AccessToken).setAdditionalInformation(map);
        return oAuth2AccessToken;
    }
}

自定义类 MyAdditionalInformation 实现 TokenEnhancer 接口,并实现接口中的 enhance 方法。enhance 方法中的 OAuth2AccessToken 参数就是已经生成的 access_token 信息,我们可以从OAuth2AccessToken 中取出已经生成的额外信息,然后在此基础上追加自己的信息。

需要提醒一句,其实我们配置的 JwtAccessTokenConverter 也是 TokenEnhancer 的一个实例

配置完成之后,我们还需要在 auth-server 里面的 AuthorizationServerConfig 中修改 AuthorizationServerTokenServices 实例,如下:

    /**
     * Jwt访问令牌转换器
     */
    @Autowired
    JwtAccessTokenConverter jwtAccessTokenConverter;

    /**
     * 我的附加信息
      */
    @Autowired
    MyAdditionalInformation myAdditionalInformation;

    @Bean //授权服务器令牌服务
    AuthorizationServerTokenServices tokenServices(){
        //默认令牌服务
        DefaultTokenServices services=new DefaultTokenServices();
        //设置客户详细信息服务
        services.setClientDetailsService(clientDetailsService()); // 替换成我们的实例 clientDetailsService 数据库
        //设置支持刷新令牌
        services.setSupportRefreshToken(true);
        //设置令牌存储
        services.setTokenStore(tokenStore);
        //令牌增强器 这是一个配置链
        TokenEnhancerChain tokenEnhancer=new TokenEnhancerChain();
        //设置令牌增强器  设置jwt的关键位置
        tokenEnhancer.setTokenEnhancers(Arrays.asList(jwtAccessTokenConverter,myAdditionalInformation));
        //设置令牌增强器
        services.setTokenEnhancer(tokenEnhancer);
        //设置访问令牌有效性秒数 在数据库中读取
//        services.setAccessTokenValiditySeconds(60*60*2);
        //设置刷新令牌有效秒数
//        services.setRefreshTokenValiditySeconds(60*60*24*7);
        return services;
    }

这里主要是是在 DefaultTokenServices 中配置 TokenEnhancer,将之前的 JwtAccessTokenConverter 和 MyAdditionalInformation 两个实例注入进来即可。

如此之后,我们的 auth-server 就算是配置成功了 。


资源服务器改造

接下来我们还需要对资源服务器进行改造,也就是 user-server,我们将 auth-server 中的AccessTokenConfig 类拷贝到 user-server 中,然后在资源服务器配置中不再配置远程校验地址,而是配置一个 TokenStore 即可:

user-server 中的 AccessTokenConfig 文件

@Configuration
public class AccessTokenConfig {
    /**
     * Jwt访问令牌转换器
     * @return
     */
    @Bean
    JwtAccessTokenConverter jwtAccessTokenConverter(){
        JwtAccessTokenConverter jwtAccessTokenConverter=new JwtAccessTokenConverter();
        jwtAccessTokenConverter.setSigningKey("mimao");
        return jwtAccessTokenConverter;
    }

    @Bean
    TokenStore tokenStore(){
        return new JwtTokenStore(jwtAccessTokenConverter());
    }
}

在 ResourceServerConfig 配置一个 TokenStore

    @Autowired
    TokenStore tokenStore;

    //资源服务器安全配置器
    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        //资源编号 这是原本的远程访问的配置
//        resources.resourceId("res1").tokenServices(tokenServices());
        //使用 jwt 来获取用户的信息自动判断
        resources.resourceId("res1").tokenStore(tokenStore);
    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()//授权请求
                .antMatchers("/admin/**").hasRole("admin")
                .anyRequest().authenticated()
                .and().cors();//所有要求都过 跨域
    }

这里配置好之后,会自动调用 JwtAccessTokenConverter 将 jwt 解析出来,jwt 里边就包含了用户的基本信息,所以就不用远程校验 access_token 。


测试

这里为了方便用了 password 模式来测试:

jwt 的字符串还是挺长的,另外返回的数据中也有我们自定义的信息。可以使用一些在线的 Base64 工具自行解码 jwt 字符串的前两部分(第三部分无法解析),当然也可以通过check_token 接口来解析:

解析后就可以看到 jwt 中保存的用户详细信息了。

拿到 access_token 之后,我们就可以去访问 user-server 中的资源了,访问方式跟之前的一样,请求头中传入 access_token 即可:

如此之后,我们就成功的将 OAuth2 和 Jwt 结合起来了。


原理

普通的 access_token 到底是怎么变为 jwt 的?jwt 和认证信息又是如何自动转换的?

首先我们知道,access_token 的生成,默认是在 DefaultTokenServices#createAccessToken 方法中的,我们来看下 createAccessToken 方法:

    private OAuth2AccessToken createAccessToken(OAuth2Authentication authentication, OAuth2RefreshToken refreshToken) {
        DefaultOAuth2AccessToken token = new DefaultOAuth2AccessToken(UUID.randomUUID().toString());
        int validitySeconds = this.getAccessTokenValiditySeconds(authentication.getOAuth2Request());
        if (validitySeconds > 0) {
            token.setExpiration(new Date(System.currentTimeMillis() + (long)validitySeconds * 1000L));
        }

        token.setRefreshToken(refreshToken);
        token.setScope(authentication.getOAuth2Request().getScope());
        return (OAuth2AccessToken)(this.accessTokenEnhancer != null ? this.accessTokenEnhancer.enhance(token, authentication) : token);
    }

从这段源码中我们可以看到:

  • 1. 默认生成的 access_token 其实就是一个 UUID 字符串。
  • 2. getAccessTokenValiditySeconds 方法用来获取 access_token 的有效期,点进去这个方法,我们发现这个数字是从数据库中查询出来的,其实就是我们配置的 access_token 的有效期,我们配置的有效期单位是秒。
  • 3. 如果设置的 access_token 有效期大于 0,则调用 setExpiration 方法设置过期时间,过期时间就是在当前时间基础上加上用户设置的过期时间,注意乘以 1000 将时间单位转为毫秒。
  • 4. 接下来设置刷新 token 和授权范围 scope(刷新 token 的生成过程在 createRefreshToken 方法中,其实和 access_token 的生成过程类似)。
  • 5. 最后面 return 比较关键,这里会判断有没有 accessTokenEnhancer,如果accessTokenEnhancer 不为 null,则在 accessTokenEnhancer 中再处理一遍才返回,accessTokenEnhancer 中再处理一遍就比较关键了,就是 access_token 转为 jwt 字符串的过程。

这里的 accessTokenEnhancer 实际上是一个 TokenEnhancerChain,这个链中有一个 delegates 变量保存了我们定义的两个 TokenEnhancer(auth-server 中定义的 JwtAccessTokenConverter 和CustomAdditionalInformation),也就是说,我们的 access_token 信息将在这两个类中进行二次处理。处理的顺序是按照集合中保存的顺序,就是先在 JwtAccessTokenConverter 中处理,后在 MyAdditionalInformation 中处理,顺序不能乱,也意味着我们在 auth-server 中定义的时候,JwtAccessTokenConverter 和 MyAdditionalInformation 的顺序不能写错。

无论是 JwtAccessTokenConverter 还是 MyAdditionalInformation ,它里边核心的方法都是enhance,我们先来看 JwtAccessTokenConverter#enhance:


    public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
        DefaultOAuth2AccessToken result = new DefaultOAuth2AccessToken(accessToken);
        Map<String, Object> info = new LinkedHashMap(accessToken.getAdditionalInformation());
        String tokenId = result.getValue();
        if (!info.containsKey("jti")) {
            info.put("jti", tokenId);
        } else {
            tokenId = (String)info.get("jti");
        }

        result.setAdditionalInformation(info);
        result.setValue(this.encode(result, authentication));
        OAuth2RefreshToken refreshToken = result.getRefreshToken();
        if (refreshToken != null) {
            DefaultOAuth2AccessToken encodedRefreshToken = new DefaultOAuth2AccessToken(accessToken);
            encodedRefreshToken.setValue(refreshToken.getValue());
            encodedRefreshToken.setExpiration((Date)null);

            try {
                Map<String, Object> claims = this.objectMapper.parseMap(JwtHelper.decode(refreshToken.getValue()).getClaims());
                if (claims.containsKey("jti")) {
                    encodedRefreshToken.setValue(claims.get("jti").toString());
                }
            } catch (IllegalArgumentException var11) {
            }

            Map<String, Object> refreshTokenInfo = new LinkedHashMap(accessToken.getAdditionalInformation());
            refreshTokenInfo.put("jti", encodedRefreshToken.getValue());
            refreshTokenInfo.put("ati", tokenId);
            encodedRefreshToken.setAdditionalInformation(refreshTokenInfo);
            DefaultOAuth2RefreshToken token = new DefaultOAuth2RefreshToken(this.encode(encodedRefreshToken, authentication));
            if (refreshToken instanceof ExpiringOAuth2RefreshToken) {
                Date expiration = ((ExpiringOAuth2RefreshToken)refreshToken).getExpiration();
                encodedRefreshToken.setExpiration(expiration);
                token = new DefaultExpiringOAuth2RefreshToken(this.encode(encodedRefreshToken, authentication), expiration);
            }

            result.setRefreshToken((OAuth2RefreshToken)token);
        }

        return result;
    }

这段代码虽然比较长,但是却很好理解:

  • 1. 首先构造一个 DefaultOAuth2AccessToken 对象。
  • 2. 将 accessToken 中的附加信息拿出来(此时默认没有附加信息)。
  • 3. 获取旧的 access_token(就是上一步 UUID 字符串),将之作为附加信息存入到 info 中(返回的 jwt 中有一个 jti,其实就是这里存入进来的)。
  • 4. 将附加信息存入 result 中。
  • 5. 对 result 进行编码,将编码结果作为新的 access_token,这个编码的过程就是 jwt 字符串生成的过程。
  • 6. 接下来是处理刷新 token,刷新 token 如果是 jwt 字符串,则需要有一个解码操作,否则不需要,刷新 token 如果是 ExpiringOAuth2RefreshToken 的实例,表示刷新 token 已经过期,则重新生成一个,这里的逻辑比较简单,就不啰嗦了。

最后我们再来看看这里多次出现的 encode 方法,就是 jwt 字符串编码的过程:

    protected String encode(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
        String content;
        try {
            content = this.objectMapper.formatMap(this.tokenConverter.convertAccessToken(accessToken, authentication));
        } catch (Exception var5) {
            throw new IllegalStateException("Cannot convert access token to JSON", var5);
        }

        String token = JwtHelper.encode(content, this.signer).getEncoded();
        return token;
    }

我们可以看到,这里首先是把用户信息和 access_token 生成一个 JSON 字符串,然后调用 JwtHelper.encode 方法进行 jwt 编码。


jwt 解码

在我们获取到jwt生成的token令牌后,需要解析token来获取用户的信息与权限等。

因为在生成的时候使用了 JwtHelper 来编码,所以在解码的时候也可以通过 JwtHelper 来解码。

例子:

    public String getClaims(String token){
        String key = "user_token:"+ token;
        String value = stringRedisTemplate.opsForValue().get(key);
        if (StringUtils.isEmpty(value)) {
            return null;
        }
        Gson gson = new Gson();
        Map<String, Object> mapBody = new HashMap<String, Object>();
        mapBody = gson.fromJson(value, mapBody.getClass());
        String jwtString = String.valueOf(mapBody.get("jwtToken"));
        // 解析 JWT 编码
        Jwt jwt = JwtHelper.decode(jwtString);
        return jwt.getClaims();
    }

解码后就可以获取用户的信息进行权限等判断

除了利用 JwtHelper 还可以用 nimbus-jose-jwt 解码

<dependency>
    <groupId>com.nimbusds</groupId>
    <artifactId>nimbus-jose-jwt</artifactId>
    <version>8.19</version>
</dependency>

首先你需要有准确可以解码的 JWT。

然后将这个 JWT 转换为 SignedJWT

SignedJWT sjwt = SignedJWT.parse(token);

然后你可以使用下面的代码获得所有的 claims。nimbus-jose-jwt 返回的结果是 set。随后你就可以根据返回的 Set 去查询你需要的内容了。

如本文“对您有用”,欢迎随意打赏作者,让我们坚持创作!

1 打赏
Enamiĝu al vi
不要为明天忧虑.因为明天自有明天的忧虑.一天的难处一天当就够了。
543文章 68评论 294点赞 594607浏览

随机文章
Redis笔记—安装
5年前
MyBatis笔记12—动态 SQL
5年前
SpringSecurity—用 Swagger 测试接口,在请求头中携带 Token
4年前
SpringCloud—Consul(一)(Ribbon)
5年前
Java—并发编程(六)JUC锁 – (3)公平锁
4年前
博客统计
  • 日志总数:543 篇
  • 评论数目:68 条
  • 建站日期:2020-03-06
  • 运行天数:1927 天
  • 标签总数:23 个
  • 最后更新:2024-12-20
Copyright © 2025 网站备案号: 浙ICP备20017730号 身体没有灵魂是死的,信心没有行为也是死的。
主页
页面
  • 归档
  • 摘要
  • 杂图
  • 问题随笔
博主
Enamiĝu al vi
Enamiĝu al vi 管理员
To be, or not to be
543 文章 68 评论 594607 浏览
测试
测试
看板娘
赞赏作者

请通过微信、支付宝 APP 扫一扫

感谢您对作者的支持!

 支付宝 微信支付