Spring cloud OAuth2 and JWT

参考:

  1. Spring cloud oauth2.0学习总结
  2. spring-security-oauth2
  3. 官方spring_cloud_security
  4. 理解OAuth 2.0
  5. where-to-store-your-jwts
  6. JWT RFC标准
  7. 理解JWT

官方文档有句话,可以看出官方基本上也是推荐使用OAuth2进行授权管理、JWT作为令牌管理:

基于Spring BootSpring Security OAuth2可以很快速创建单点登录、令牌relay和令牌交换。

OAuth 2.0 简单介绍

角色

先区分下OAuth 2.0 中有哪些角色,注意这里根据自己理解来写的,阮一峰博客里写的更精确:

  1. Client: 客户端,也就是Third-party application - 第三方应用程序
  2. Service:服务端,也就是服务的提供者
  3. User: 用户,也就是Resource Owner - 资源所有者
  4. User Agent:用户代理,如浏览器,下文中将其与Client合并考虑。
  5. Authorization Server:认证服务器,即服务提供商专门用来处理认证的服务器。
  6. Resource Server:资源服务器,即服务提供商存放用户生成的资源的服务器。

模式

在不需要第三方认证支持时,我们常用的就是简化模式:

image

步骤如下:

(A)客户端将用户导向认证服务器。
(B)用户决定是否给于客户端授权。
(C)假设用户给予授权,认证服务器将用户导向客户端指定的"重定向URI",并在URI的Hash部分包含了访问令牌。
(D)浏览器向资源服务器发出请求,其中不包括上一步收到的Hash值。
(E)资源服务器返回一个网页,其中包含的代码可以获取Hash值中的令牌。
(F)浏览器执行上一步获得的脚本,提取出令牌。
(G)浏览器将令牌发给客户端。

上面都是非常严谨的描述,大家可以详细看阮一峰的博客,或者RFC标准, 下面主要分析下微服务下如何

简单对比下简单模式和授权模式的差别:

  1. 简化模式主要用于用户直接登录,至于是通过web、App还是其它方式是无所谓的,用户将给客户端完全的授权;这里只涉及到用户 -> 客户端 -> 服务提供者
  2. 授权模式是给第三方部分/全部权限以便于其给用户提供服务,这里涉及到 用户 -> 客户端 -> 第三方服务提供者 -> 服务提供者

重点是授权模式给 第三方部分用户权限,而且这时候客户端大部分也是第三方提供的。

团队学习时,很多人困惑: 为何在授权模式下需要将code给客户端, 然后再让第三方去获取Token?

  1. 授权模式下,数据是在第三方和后台直接交互,用户只是给了授权,所以Token肯定要给第三方
  2. 如果直接将Token给客户端,但客户端本身也可能是伪造的,但它拿了code是没有用的,无法访问数据

Spring Cloud 微服务下

Spring Cloud 下我们这里使用简化模式,主要是登录、授权、Token管理,角色大体如下:

  1. User: 也就是用户,用户一般直接与Client交互,REST API后台一般不需要考虑。
  2. Gateway + Resource Server :资源服务器对请求进行认证,一般整合在网关中,这样可以很方便的统一处理所有请求。
  3. Authorization Server: 授权服务器,进行授权和Token管理。
  4. Client: 调用API的应用,一般是前端、移动App或者第三方应用
  5. Token Store: 令牌存储,多个服务如果每次请求都通过授权服务器进行Token查询,效率底下,所以需要统一存储、交互令牌信息,常用Redis
  6. Services: 提供正在业务/功能/API的服务。

大概画个图,这里以Client为前端为例,注意不涉及用户和前端的交互:


image.png
  1. 服务接收请求后,如果需要还会解析Token获取用户信息
  2. 实际过程中,还会有Token的刷新、删除等操作
  3. 如果使用JWT,那么可以不保存token,当然也可以保存,Spring中默认JwtTokenStore实际上是没有存储。可以参考 jwt-authentication-how-to-implement-logout

Spring Cloud OAuth将基本的功能都实现。

JWT 简介

规范

JWT -- Json Web Token, 如其名,使用Json方式保存Web Token的协议。网上有各种解读,个人理解,这就是一个 客户端Session - Session保存在客户端,而不是通常的保存在服务端。

构成

JWT三部分组成:

  1. Header 头部:JSON方式描述JWT基本信息,如类型和签名算法。使用Base64编码为字符串
  2. Payload 载荷:JSON方式描述JWT信息,除了标准定义的,还可以添加自定义的信息。同样使用Base64编码为字符串。
    • iss: 签发者
    • sub: 用户
    • aud: 接收方
    • exp(expires): unix时间戳描述的过期时间
    • iat(issued at): unix时间戳描述的签发时间
  3. Signature 签名:将前两个字符串用 . 连接后,使用头部定义的加密算法,利用密钥进行签名,并将签名信息附在最后。

注意: Payload 使用 Base64编码,所以就是明文的,不要存放任何机密信息。

优缺点

当然带来一些好处:

  1. 服务端内存占用少了
  2. 不需要维护session状态了,真正无状态
  3. 单点登录 so easy,只要后台服务能解读,Cookie 设置为顶级域名

有好处当然就有不太好的:

  1. 每个请求就要对JWT进行解密,验证
  2. Token有效期只有超时,没有退出。当然有一些做法,上面也提到了,jwt-authentication-how-to-implement-logout
  3. XSS攻击问题,一个讨论: Is it OK to store the JWT in local/session storage

我个人的看法是: 使用JWT,同时在Redis保存信息,在API网关进行详细的验证;各服务则只简单校验Token本身是否篡改。

Spring Cloud OAuth 解读

角色

Spring Cloud OAuth中将角色为三个,这点从源码中包org.springframework.security.oauth2.config.annotation.web.configurers 中包含三个Enable注解就可以看出来:

  1. EnableAuthorizationServer -- 使能授权服务器
  2. EnableResourceServer -- 使能资源服务器
  3. EnableOAuth2Client -- 使能客户端,如需要第三方授权来调用,应该使用此注解。

AuthorizationServer 授权服务配置

一. 首先当然需要使能,在配置类或 Application 上类添加注解: @EnableAuthorizationServer,添加该注解后会自动添加OAuth2的多个endpoint, 相关实现代码在包 org.springframework.security.oauth2.provider.endpoint:

  1. /oauth/authorize:验证接口, AuthorizationEndpoint
  2. /oauth/token:获取token
  3. /oauth/confirm_access:用户授权
  4. /oauth/error:认证失败
  5. /oauth/check_token:资源服务器用来校验token
  6. /oauth/token_key:jwt模式下获取公钥;位于:TokenKeyEndpoint ,通过 JwtAccessTokenConverter 访问key

二. 配置入口为接口:AuthorizationServerConfigurer, 通过扩展AuthorizationServerConfigurerAdapter 实现来进行配置。

Spring Boot 2中很多 Adapter已经取消,直接利用 Java8 Interface Default特性来实现,不过到我写此文时 security 还没改,当然也许是我没注意到。

三. 简单看一下 AuthorizationServerConfigurer 接口的方法, 一共配置三个属性:

  1. AuthorizationServerSecurityConfigurer :声明安全约束,哪些允许访问,哪些不允许访问。配置 AuthorizationServer 的安全属性,也就是endpoint /oauth/token/oauth/authorize 则和其它用户 REST 一样保护。可以不配置。
  2. ClientDetailsServiceConfigurer : 配置 ClientDetailsService 独立client客户端的信息。包括权限范围、授权方式、客户端权限等配置。授权方式有4种:implicit, client_redentials, password , authorization_code, 其中密码授权方式必须结合 AuthenticationManager 进行配置。必须至少配置一个客户端。
  3. AuthorizationServerEndpointsConfigurer : 配置AuthorizationServer 端点的非安全属性,也就是 token 存储方式、token 配置、用户授权模式等。默认不需做任何配置,除非使用 密码授权方式, 这时候必须配置 AuthenticationManager。

四. 其中,Token管理:

  1. Token 生命周期管理接口 AuthorizationServerTokenServices, 默认使用: DefaultTokenServices
  2. Token存储通过配置 TokenStore,默认使用内存存储。AuthorizationServerEndpointsConfigurerDefaultTokenServices 入口配置。配置方式有
    • InMemoryTokenStore 默认方式,保存在本地内存
    • JdbcTokenStore 存储数据库
    • RedisTokenStore 存储Redis,这应该是微服务下比较常用方式
    • JwtTokenStore
  3. AccessTokenConverter

五. 加密算法配置

在spring5之后,必须配置加密算法。

  1. 测试时候可以用无加密算法,参考:no-passwordencoder-mapped-id-null
    @SuppressWarnings("deprecation")
    @Bean
    public NoOpPasswordEncoder passwordEncoder() {
        return (NoOpPasswordEncoder) NoOpPasswordEncoder.getInstance();
    }
  1. 配置加密算法,当然也可以配置其它算法:
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

六. 实际例子:

代码这里不贴了,可以参考上面的参考材料。

测试:

curl -X POST \
  http://127.0.0.1:<端口>/oauth/token \
  -H 'Authorization: Basic <xxxxxxx>' \
  -H 'Cache-Control: no-cache' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'username=admin&password=admin&grant_type=password'

其中:Basic <xxxxxxx> 根据client_id和secret计算,我用的postman测试,其中授权方式选择 Basic Auth, Username就是client_id,secret 就是 password,postman 会自动计算 Authorization 字段。

ResourceServer 资源服务器配置

一. 在配置类或 Application 类上添加注解:@EnableResourceServer

二. 配置接口为: ResourceServerConfigurer,继承实现ResourceServerConfigurerAdapter 即可。接口有两个配置:

  1. ResourceServerSecurityConfigurer : 配置资源服务器安全属性,如Token的配置,这些是与 AuthorizationServer 授权服务器的配置是匹配的。
  2. HttpSecurity : 配置资源的保护

如何整合 ResourceServer 到 zuul,网上有很多教程,这里不多说。但是到目前为止,spring cloud gateway 并没有整合 OAuth2,需要自己实现,且其与 ResourceServer 实现不能整合(也可能我没找到)

https://github.com/spring-cloud/spring-cloud-gateway/issues/179

Spring Cloud Gateway 整合 Security

一些参考

https://github.com/spring-projects/spring-security/issues/4807
https://github.com/spring-cloud/spring-cloud-gateway/issues/144

https://stackoverflow.com/questions/46798705/is-there-working-example-of-oauth2-with-webflux

主要参考:
https://stackoverflow.com/questions/47354171/spring-webflux-custom-authentication-for-api

临时的解决方案,后续Spring应该会将其完全整合。
gradle 添加依赖:

  compile 'org.springframework.boot:spring-boot-starter-security'
  compile 'io.jsonwebtoken:jjwt:0.9.0'

添加SecurityFilter

@Configuration
@EnableWebFluxSecurity
public class SecurityFilter {

    @Autowired
    private SecurityContextRepository securityContextRepository;

    @Bean
    SecurityWebFilterChain springWebFilterChain(ServerHttpSecurity http) throws Exception {
        return http
                // Disable default security.
                .csrf().disable()
                .httpBasic().disable()
                .formLogin().disable()
                .logout().disable()
                // config auth
                .securityContextRepository(securityContextRepository)
                // Disable authentication for `/oauth/**` routes.
                .authorizeExchange()
                .pathMatchers("/oauth/**").permitAll()
                .anyExchange().authenticated()
                .and()
                .build();
    }
}

主要的实现在 SecurityContextRepository,下面代码没有完全实现,大体流程:

  1. 获取Token
  2. 解析Token,这里用的 jjwt
  3. 判断Token信息,下面的代码中并没有实现,大家可以自己实现
@Component
public class SecurityContextRepository implements ServerSecurityContextRepository {

    private static final Logger logger = LoggerFactory.getLogger(SecurityFilter.class);

    @Override
    public Mono<Void> save(ServerWebExchange serverWebExchange, SecurityContext securityContext) {
        return Mono.empty();
    }

    @Override
    public Mono<SecurityContext> load(ServerWebExchange serverWebExchange) {
         // 获取Token
        String authHeader = serverWebExchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
        if (authHeader == null) {
            logger.warn("not find AUTHORIZATION");
            return Mono.empty();
        }
        String token = authHeader.replace(TOKEN_SCHEME, "").trim();

        try {
            Claims claims = Jwts.parser().setSigningKey("iotSignKey".getBytes()).parseClaimsJws(token).getBody();
            TokenInfo tokenInfo = new TokenInfo(claims);
            logger.info("token:{}  ", tokenInfo);

            // 获取授权信息
            List<GrantedAuthority> authorities = tokenInfo.getAuthorities()
                    .stream()
                    .map(SimpleGrantedAuthority::new)
                    .collect(toList());

            Authentication authentication = new JwtAuthenticationToken(authorities, tokenInfo.getUserName());
            authentication.setAuthenticated(true);

            return Mono.justOrEmpty(new SecurityContextImpl(authentication));
        } catch (SignatureException e) {
            // 验证错误
            logger.warn("jwt token parse error: {}", e.getCause());
        } catch (ExpiredJwtException e) {
            // token 超时
            logger.warn("jwt token is expired");
        } catch (MalformedJwtException e) {
            // token Malformed
            logger.warn("jwt token is malformed");
        }
        return Mono.empty();
}
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 197,737评论 5 462
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,103评论 2 375
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 144,710评论 0 326
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,909评论 1 267
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,794评论 5 358
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,557评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,939评论 3 388
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,572评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,852评论 1 293
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,871评论 2 314
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,692评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,490评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,939评论 3 300
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,114评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,409评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,971评论 2 343
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,176评论 2 339

推荐阅读更多精彩内容