SpringCloud进阶(4)–OAuth 2.0 实现单点登录

在之前的文章中,我们曾学习过,使用Redis作为缓存,去存放session来实现分布式session,以此完成不同服务间的分布式权限校验。实际上我们称这种登录模式为多点登录。

但这样实现也存在几个问题:

  • 服务间调用障碍:就如前面文章中的借阅服务为例,如果我们直接使用分布式Session,就会发现借阅服务报错,因为借阅服务需要请求book服务和user服务,而两者又需要cookie来进行验证,显然,会存在报错。即在一个服务上登录,并不能保证可以访问其他方法。
  • 验证系统冗余:使用分布式session验证同时存在一个问题–每个服务都有自己的验证模块,但实际上,这个系统是存在冗余的。

那么能否实现只在一个服务进行登录,就可以访问其他服务的方法呢?

这里我们可以使用OAuth 2.0单点登录实现基于三方应用的访问用户信息权限。

四种授权模式

1.客户端模式

这是最简单的一种模式,我们可以直接向验证服务器请求一个Token,服务器拿到这个令牌后,经过验证才能去访问资源,这样所有服务都能知道我们是否成功登录了:

虽然这种模式比较简便,但是已经失去了用户验证的意义,压根就不是给用户校验准备的,而是更适用于服务内部调用的场景。

2.密码模式

密码模式和客户端模式类似,就是多了用户名和密码信息,用户需要提供对于账号的用户名和密码,才能获取到token:

3.隐式授权模式

首先用户访问页面时,会重定向到认证服务器,接着认证服务器给用户一个认证页面,等待用户授权,用户填写信息完成授权后,认证服务器返回Token。

它适用于没有服务端的第三方应用页面,并且相比前面一种形式,验证都是在验证服务器进行的,敏感信息不会轻易泄露,但是Token依然存在泄露的风险。

4.授权码模式

这种模式是最安全的一种模式,也是推荐使用的一种,比如我们手机上的很多App都是使用的这种模式。

相比隐式授权模式,它并不会直接返回Token,而是返回授权码,真正的Token是通过应用服务器访问验证服务器获得的。在一开始的时候,应用服务器(客户端通过访问自己的应用服务器来进而访问其他服务)和验证服务器之间会共享一个secret,这个东西没有其他人知道,而验证服务器在用户验证完成之后,会返回一个授权码,应用服务器最后将授权码和secret一起交给验证服务器进行验证,并且Token也是在服务端之间传递,不会直接给到客户端。

这样就算有人中途窃取了授权码,也毫无意义,因为,Token的获取必须同时携带授权码和secret,但是secret第三方是无法得知的,并且Token不会直接丢给客户端,大大减少了泄露的风险。

搭建验证服务器

了解完四种授权模式,我们就需要搭建验证服务器去实现权限校验的核心,这里我们使用Spring官方提供的验证服务器。

我们需要创建新的模块auth-service,并添加依赖:(注意这里oauth2已经官方停止更新了,当前仅支持SpringBoot 2.4.0以下)

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    
    <!--  OAuth2.0依赖,不再内置了,所以得我们自己指定一下版本  -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-oauth2</artifactId>
        <version>2.2.5.RELEASE</version>
    </dependency>
</dependencies>

接着,我们修改一下配置文件:

server:
  port: 8500
  servlet:
  	#为了防止一会在服务之间跳转导致Cookie打架(因为所有服务地址都是localhost,都会存JSESSIONID)
  	#这里修改一下context-path,这样保存的Cookie会使用指定的路径,就不会和其他服务打架了
  	#但是注意之后的请求都得在最前面加上这个路径
    context-path: /sso

我们需要写两个配置类,一个是OAuth2的配置类,一个是SpringSecurity的配置类:

@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .anyRequest().authenticated()  //
                .and()
                .formLogin().permitAll();    //使用表单登录
    }
  
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
        auth
                .inMemoryAuthentication()   //直接创建一个用户,懒得搞数据库了
                .passwordEncoder(encoder)
                .withUser("test").password(encoder.encode("123456")).roles("USER");
    }
  
  	@Bean   //这里需要将AuthenticationManager注册为Bean,在OAuth配置中使用
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}
@EnableAuthorizationServer   //开启验证服务器
@Configuration
public class OAuth2Configuration extends AuthorizationServerConfigurerAdapter {

    @Resource
    private AuthenticationManager manager;

    private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();

    /**
     * 这个方法是对客户端进行配置,一个验证服务器可以预设很多个客户端,
     * 之后这些指定的客户端就可以按照下面指定的方式进行验证
     * @param clients 客户端配置工具
     */
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients
                .inMemory()   //这里我们直接硬编码创建,当然也可以像Security那样自定义或是使用JDBC从数据库读取
                .withClient("web")   //客户端名称,随便起就行
                .secret(encoder.encode("654321"))      //只与客户端分享的secret,随便写,但是注意要加密
                .autoApprove(false)    //自动审批,这里关闭,要的就是一会体验那种感觉
                .scopes("book", "user", "borrow")     //授权范围,这里我们使用全部all
                .authorizedGrantTypes("client_credentials", "password", "implicit", "authorization_code", "refresh_token");
                //授权模式,一共支持5种,除了之前我们介绍的四种之外,还有一个刷新Token的模式
                //这里我们直接把五种都写上,方便一会实验,当然各位也可以单独只写一种一个一个进行测试
                //现在我们指定的客户端就支持这五种类型的授权方式了
    }

    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) {
        security
                .passwordEncoder(encoder)    //编码器设定为BCryptPasswordEncoder
                .allowFormAuthenticationForClients()  //允许客户端使用表单验证,一会我们POST请求中会携带表单信息
                .checkTokenAccess("permitAll()");     //允许所有的Token查询请求
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
        endpoints
                .authenticationManager(manager);
        //由于SpringSecurity新版本的一些底层改动,这里需要配置一下authenticationManager,才能正常使用password模式
    }
}

我们先来使用PostMan试试最简单的客户端模式:

我们通过添加一个grant_type来表明我们的授权方式,发起请求后,可以得到json发来的token。

我们还可以访问/oauth/check_token来验证Token是否有效

可以看到active为true,表示我们刚刚申请到的Token是有效的。

接着来测试一下第二种password模式,首先需要提供具体的用户名和密码,授权模式定义为password:

接着我们需要在请求头中添加Basic验证信息,这里我们直接填写id和secret即可:

可以看到在请求头中自动生成了Basic验证相关内容:

响应成功,得到Token信息,并且这里还多出了一个refresh_token,这是用于刷新Token的

查询Token信息之后还可以看到登录的具体用户以及角色权限等。

接着我们来看隐式授权模式,这种模式我们需要在验证服务器上进行登录操作,而不是直接请求Token,验证登录请求地址:http://localhost:8500/sso/oauth/authorize?client_id=web&response_type=token

但是登录之后我们发现出现了一个错误:

这是因为登录成功之后,验证服务器需要将结果给回客户端,所以需要提供客户端的回调地址,这样浏览器就会被重定向到指定的回调地址并且请求中会携带Token信息,这里我们随便配置一个回调地址:

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
    clients
            .inMemory()
            .withClient("web")
            .secret(encoder.encode("654321"))
            .autoApprove(false)
            .scopes("book", "user", "borrow")
            .redirectUris("http://localhost:8201/login")   //可以写多个,当有多个时需要在验证请求中指定使用哪个地址进行回调
            .authorizedGrantTypes("client_credentials", "password", "implicit", "authorization_code", "refresh_token");
}

接着重启验证服务器,再次访问可以看到这里会让我们选择哪些范围进行授权,就像我们在微信小程序中登陆一样,会让我们授予用户信息权限、支付权限、信用查询权限等,我们可以自由决定要不要给客户端授予访问这些资源的权限,这里我们全部选择授予:

授予之后,可以看到浏览器被重定向到我们刚刚指定的回调地址中,并且携带了Token信息。

最后我们来看看第四种最安全的授权码模式,这种模式其实流程和上面是一样的,但是请求的是code类型:http://localhost:8500/sso/oauth/authorize?client_id=web&response_type=code

可以看到访问之后,依然会进入到回调地址,但是这时给的就是授权码了,而不是直接给Token,那么这个Token该怎么获取呢?

,我们需要携带授权码和secret一起请求,才能拿到Token,正常情况下是由回调的服务器进行处理,这里我们就在Postman中进行,我们复制刚刚得到的授权码,接口依然是localhost:8500/sso/oauth/token

最后还有一个是刷新令牌使用的,当我们的Token过期时,我们就可以使用这个refresh_token来申请一个新的Token:

但是执行之后我们发现会直接出现一个内部错误,这里还需要我们单独配置一个UserDetailsService,我们直接把Security中的实例注册为Bean:

@Bean
@Override
public UserDetailsService userDetailsServiceBean() throws Exception {
    return super.userDetailsServiceBean();
}

然后在Endpoint中设置:

@Resource
UserDetailsService service;

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
    endpoints
            .userDetailsService(service)
            .authenticationManager(manager);
}

再次刷新token就会返回一个新的。

基于@EnableOAuth2Sso实现

前面我们将验证服务器已经搭建完成了,现在我们就来实现一下单点登陆吧,SpringCloud为我们提供了客户端的直接实现,我们只需要添加一个注解和少量配置即可将我们的服务作为一个单点登陆应用,使用的是第四种授权码模式。

一句话来说就是,这种模式只是将验证方式由原本的默认登录形式改变为了统一在授权服务器登陆的形式。

依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-oauth2</artifactId>
    <version>2.2.5.RELEASE</version>
</dependency>

我们只需要直接在启动类上添加即可:

@EnableOAuth2Sso
@SpringBootApplication
public class BookApplication {
    public static void main(String[] args) {
        SpringApplication.run(BookApplication.class, args);
    }
}

接着我们需要在配置文件中配置我们的验证服务器相关信息:

security:
  oauth2:
    client:
      #不多说了
      client-id: web
      client-secret: 654321
      #Token获取地址
      access-token-uri: http://localhost:8500/sso/oauth/token
      #验证页面地址
      user-authorization-uri: http://localhost:8500/sso/oauth/authorize
    resource:
      #Token信息获取和校验地址
      token-info-uri: http://localhost:8500/sso/oauth/check_token

使用jwt存储Token

JSON Web Token令牌(JWT)是一个开放标准(RFC 7519),它定义了一种紧凑和自成一体的方式,用于在各方之间作为JSON对象安全地传输信息。这些信息可以被验证和信任,因为它是数字签名的。JWT可以使用密钥(使用HMAC算法)或使用RSAECDSA进行公钥/私钥对进行签名。

实际上,我们之前都是携带Token向资源服务器发起请求后,资源服务器由于不知道我们Token的用户信息,所以需要向验证服务器询问此Token的认证信息,这样才能得到Token代表的用户信息,但是各位是否考虑过,如果每次用户请求都去查询用户信息,那么在大量请求下,验证服务器的压力可能会非常的大。而使用JWT之后,Token中会直接保存用户信息,这样资源服务器就不再需要询问验证服务器,自行就可以完成解析,我们的目标是不联系验证服务器就能直接完成验证。

JWT令牌的格式如下:

一个JWT令牌由3部分组成:标头(Header)、有效载荷(Payload)和签名(Signature)。在传输的时候,会将JWT的3部分分别进行Base64编码后用.进行连接形成最终需要传输的字符串。

  • 标头:包含一些元数据信息,比如JWT签名所使用的加密算法,还有类型,这里统一都是JWT。
  • 有效载荷:包括用户名称、令牌发布时间、过期时间、JWT ID等,当然我们也可以自定义添加字段,我们的用户信息一般都在这里存放。
  • 签名:首先需要指定一个密钥,该密钥仅仅保存在服务器中,保证不能让其他用户知道。然后使用Header中指定的算法对Header和Payload进行base64加密之后的结果通过密钥计算哈希值,然后就得出一个签名哈希。这个会用于之后验证内容是否被篡改。

这里我们就可以利用jwt,将我们的Token采用新的方式进行存储:

这里我们使用最简单的一种方式,对称密钥,我们需要对验证服务器进行一些修改:

@Bean
public JwtAccessTokenConverter tokenConverter(){  //Token转换器,将其转换为JWT
    JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
    converter.setSigningKey("lbwnb");   //这个是对称密钥,一会资源服务器那边也要指定为这个
    return converter;
}

@Bean
public TokenStore tokenStore(JwtAccessTokenConverter converter){  //Token存储方式现在改为JWT存储
    return new JwtTokenStore(converter);  //传入刚刚定义好的转换器
}
@Resource
TokenStore store;

@Resource
JwtAccessTokenConverter converter;

private AuthorizationServerTokenServices serverTokenServices(){  //这里对AuthorizationServerTokenServices进行一下配置
    DefaultTokenServices services = new DefaultTokenServices();
    services.setSupportRefreshToken(true);   //允许Token刷新
    services.setTokenStore(store);   //添加刚刚的TokenStore
    services.setTokenEnhancer(converter);   //添加Token增强,其实就是JwtAccessTokenConverter,增强是添加一些自定义的数据到JWT中
    return services;
}

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
    endpoints
            .tokenServices(serverTokenServices())   //设定为刚刚配置好的AuthorizationServerTokenServices
            .userDetailsService(service)
            .authenticationManager(manager);
}

我们对资源服务器进行配置:

security:
  oauth2:
    resource:
      jwt:
        key-value: lbwnb #注意这里要跟验证服务器的密钥一致,这样算出来的签名才会一致