OAuth2

今天是清明假期最后一天,跟以往一样下起了细雨。天气越舒适,人的思绪就好像会越乱。年轻人和长辈的想法总会有出入,早上家人提出的话题被我草草结束。虽表面上结束了这个话题,但其实关于这个话题的联想一直脑海萦绕。还是写一篇技术博文冷静一下吧,生活上事情不提太多,毕竟这里是技术频道。 😀

上个月我们小组接到一个新项目。其中登录模块允许用户使用第三方账户登录,以下简称“账户系统”。后来运维“账户系统”的公司给我们发了一份技术文档,我在文档看完后就知道该“账户系统”使用了现在业界比较流行的“OAuth2开放授权协议”来允许⽤户授权第三⽅应⽤访问他们存储在服务提供者里的信息。

恰好最近也在看OAuth2的资料,于是顺便结合新项目(以下简称“表单系统”)来谈论一下OAuth2。首先通过简图来介绍一下系统的结构和方案,如下图1:

图1 系统结构简图

上图中省略了数据库和其他组件,实现OAuth2协议的代码都已经在账户系统(资源服务器和认证服务器)完成。我们开发的表单系统提供必要的参数(client_id, secret…)即可获取账户系统返回的授权码,再用授权码换取token,用token换取用户信息并保存,最后表单系统给用户创建session。这样就完成了‘用户授权“表单系统”访问他们储存在账户系统里的信息’登录的流程。简单流程图如下图2:

图2 登录流程

OAuth2常用的两种颁发token授权方式:

  1. 授权码(authorization-code)
  2. 密码式(password)

上述的表单系统登录使用的是第一种授权码模式,也是OAuth2协议里最复杂的一种模式,授权码模式使⽤到了回调地址,微博、微信、QQ等第三⽅登录就是这种模式。

下面笔者会结合代码梳理常用方式中比较简单的密码式。代码使用Spring Cloud OAuth2构建微服务统⼀认证服务。

Spring Cloud OAuth2 是 Spring Cloud 体系对OAuth2协议的实现,可以⽤来做多个微服务的统⼀认证(验证身份合法性)授权(验证权限)。通过向OAuth2服务(统⼀认证授权服务)发送某个类型的grant_type进⾏集中认证和授权,从⽽获得access_token(访问令牌),⽽这个token是受其他微服务信任的。简单流程如下图3:

图3 微服务统一认证简单流程

搭建认证服务器(Authorization Server)

认证服务器(Authorization Server),负责颁发token。注意:运行此服务前需要先运行Eureka服务。

新建项⽬robin-cloud-oauth-server-9999,pom.xml:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>robin-parent</artifactId>
<groupId>com.robin</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>

<groupId>com.robin</groupId>
<artifactId>robin-cloud-oauth-server-9999</artifactId>


<dependencies>
<!--导⼊Eureka Client依赖-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<!--导⼊spring cloud oauth2依赖-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.security.oauth.boot</groupId>
<artifactId>spring-security-oauth2-
autoconfigure</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.security.oauth.boot</groupId>
<artifactId>spring-security-oauth2-autoconfigure</artifactId>
<version>2.1.11.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
<version>2.3.4.RELEASE</version>
</dependency>
</dependencies>

</project>

application.yml

server:
port: 9999
spring:
application:
name: robin-cloud-oauth-server
eureka:
client:
service-url:
defaultZone: http://robincloudeurekaservera:8761/eureka/,http://robincloudeurekaserverb:8762/eureka/ #把eureka集群中的所有url都填写了进来,也可以只写⼀台,因为各个eureka server可以同步注册表
instance:
#使⽤ip注册,否则会使⽤主机名注册
prefer-ip-address: true
instance-id: ${spring.cloud.client.ipaddress}:${spring.application.name}:${server.port}:@project.version@

认证服务器配置类:

package com.robin.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.token.AuthorizationServerTokenServices;
import org.springframework.security.oauth2.provider.token.DefaultTokenServices;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.InMemoryTokenStore;

/**
*
@author Robin
*
@date 2021/4/5
*
* 当前类为Oauth2 server的配置类(需要继承特定的⽗类
* AuthorizationServerConfigurerAdapter)
*/
@Configuration
@EnableAuthorizationServer // 开启认证服务器功能
public class OauthServerConfiger extends AuthorizationServerConfigurerAdapter {

@Autowired
AuthenticationManager authenticationManager;

/**
* 认证服务器最终是以api接⼝的⽅式对外提供服务(校验合法性并⽣成令牌、校验令牌等)
* 那么,以api接⼝⽅式对外的话,就涉及到接⼝的访问权限,需要在这⾥进⾏必要的配置
*
* ⽤来配置token端点的安全约束
*
*
@param security
*
@throws Exception
*/
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
super.configure(security);
security.allowFormAuthenticationForClients() // 允许客户端表单验证
.tokenKeyAccess("permitAll()") // 开启端⼝/oauth/token_key的访问权限
.checkTokenAccess("permitAll()"); // 开启端⼝/oauth/check_token的访问权限
}

/**
* 客户端详情配置,如client_id,secret
* ⽤来配置客户端详情服务(ClientDetailsService),客户端详情信息在这⾥进⾏初始化
* 客户端详情信息写死在这里或者是通过数据库来存储调取详情信息
*
*
@param clients
*
@throws Exception
*/
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
super.configure(clients);
clients.inMemory() // 信息储存的地方,内存/数据库
.withClient("client_robin") // // 添加⼀个client配置,指定其client_id
.secret("123") // 指定客户端的密码/安全码
.resourceIds("autodeliver") // 指定客户端所能访问资源id清单,此处的资源id与具体的资源服务器上的配置⼀样
.authorizedGrantTypes("password","refresh_token") // 认证类型/令牌颁发模式,可以配置多个,但是不⼀定都⽤,具体使⽤哪种⽅式颁发token,需要客户端调⽤的时候传递参数指定
.scopes("all"); // 客户端的权限范围
}

/**
* 这⾥配置token令牌管理相关
* token此时就是⼀个字串,当下的token需要在服务器端存储
*
*
@param endpoints
*
@throws Exception
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
super.configure(endpoints);
endpoints.tokenStore(tokenStore()) // 指定token的存储方法
.tokenServices(authorizationServerTokenServices())
.authenticationManager(authenticationManager) // 指定认证管理器
.allowedTokenEndpointRequestMethods(HttpMethod.GET, HttpMethod.POST);
}

/**
*
* 该⽅法⽤于创建tokenStore对象
* token以什么形式存储, 这里是存在内存
*
*
@return
*/
public TokenStore tokenStore() {
return new InMemoryTokenStore();
}

public AuthorizationServerTokenServices authorizationServerTokenServices() {
// 使⽤默认实现
DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
defaultTokenServices.setSupportRefreshToken(true); // 是否开启token刷新
defaultTokenServices.setTokenStore(tokenStore());
// 设置token有效时间(⼀般设置为2个⼩时)
// 这里设置了20秒
defaultTokenServices.setAccessTokenValiditySeconds(20); // access_token就是我们请求资源需要携带的令牌
// 设置刷新token的有效时间
defaultTokenServices.setRefreshTokenValiditySeconds(259200); // 3天
return defaultTokenServices;
}
}

关于TokenStore:

  • InMemoryTokenStore
    • 默认采⽤,它可以完美的⼯作在单服务器上(即访问并发量 压⼒不⼤的情况下,并且它在失败的时候不会进⾏备份),⼤多数的项⽬都可以使⽤这个版本的实现来进⾏尝试,你可以在开发的时候使⽤它来进⾏管理,因为不会被保存到磁盘中,所以更易于调试。
  • JdbcTokenStore
    • 这是⼀个基于JDBC的实现版本,令牌会被保存进关系型数据库。使⽤这个版本的实现时, 你可以在不同的服务器之间共享令牌信息,使⽤这个版本的时候请注意把”spring-jdbc”这个依赖加⼊到你的 classpath当中。
  • JwtTokenStore
    • 这个版本的全称是 JSON Web Token(JWT),它可以把令牌相关的数据进⾏编码(因此对于后端服务来说,它不需要进⾏存储,这将是⼀个重⼤优势),缺点就是这个令牌占⽤的空间会⽐较⼤,如果你加⼊了⽐较多⽤户凭证信息。

认证服务器安全配置类

package com.robin.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

import java.util.ArrayList;

/**
* 该配置类,主要处理⽤户名和密码的校验等事宜
*
*
@author Robin
*
@date 2021/4/5
*/
public class SecurityConfiger extends WebSecurityConfigurerAdapter {

/**
* 注册认证管理器对象到容器
*/
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}

/**
* 密码编码对象(密码不进⾏加密处理)
*
@return
*/
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}

@Autowired
private PasswordEncoder passwordEncoder;

/**
* 处理⽤户名和密码验证事宜
* 1)客户端传递username和password参数到认证服务器
* 2)⼀般来说,username和password会存储在数据库中的⽤户表中
* 3)根据⽤户表中数据,验证当前传递过来的⽤户信息的合法性
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 在这个⽅法中就可以去关联数据库了,当前我们先把⽤户信息配置在内存中
// 实例化⼀个⽤户对象(相当于数据表中的⼀条⽤户记录)
UserDetails user = new User("admin","123456",new ArrayList<>());
auth.inMemoryAuthentication()
.withUser(user)
.passwordEncoder(passwordEncoder);
}
}

资源服务器(Resource Server)

资源服务配置类

package com.robin.config;

import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configurers.ResourceServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.token.RemoteTokenServices;

/**
*
@author Robin
*
@date 2021/4/5
*/
public class ResourceServerConfiger extends ResourceServerConfigurerAdapter {

private String sign_key = "robin123"; // jwt签名密钥

/**
* 该⽅法⽤于定义资源服务器向远程认证服务器发起请求,进⾏token校验等事宜
*
*
@param resources
*
@throws Exception
*/
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
// 设置当前资源服务的资源id
resources.resourceId("autodeliver");
// 定义token服务对象(token校验就应该靠token服务对象)
RemoteTokenServices remoteTokenServices = new RemoteTokenServices();
// 校验端点/接⼝设置
remoteTokenServices.setCheckTokenEndpointUrl("http://localhost:9999/oauth/check_token");
// 携带客户端id和客户端安全码
remoteTokenServices.setClientId("client_robin");
remoteTokenServices.setClientSecret("123");
resources.tokenServices(remoteTokenServices);
}

/**
* 通过此方法设置API接口访问权限
*
*
@param http
*
@throws Exception
*/
@Override
public void configure(HttpSecurity http) throws Exception {
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) // 设置session的创建策略(此处是根据需要创建)
.and()
.authorizeRequests()
// 正则表达式,/autodeliver和/demo为前缀的链接需要被验证
// 其他请求不验证
.antMatchers("/autodeliver/**").authenticated()
.antMatchers("/demo/**").authenticated()
.anyRequest().permitAll();
}
}

后续补充使⽤ JWT 进⾏改造,使⽤JWT机制之后资源服务器不需要访问认证服务。使用JWT将⽤户信息存储到token中,让客户端⼀直持有这个token,使token的验证也在资源服务器进⾏,这样避免资源服务器和认证服务器频繁的交互。

后续也会将相关代码上传至Github。