3、OAuth2

OAuth2

OAuth 2.0 是目前最流行的授权标准(协议),用来授权第三方应用,获取用户数据。

OAuth 就是一种授权机制。数据的所有者告诉系统,同意授权第三方应用进入系统,获取这些数据。系统从而产生一个短期的进入令牌(token),用来代替密码,供第三方应用使用。

CAS的单点登录和OAuth2的区别

SSO :单点登录(Single sign-on)是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。

CAS :中央认证服务(Central Authentication Service),一个基于Kerberos票据方式实现SSO单点登录的框架,为Web 应用系统提供一种可靠的单点登录解决方法(属于 Web SSO )。

微服务的认证和授权思路

通常微服务的认证和授权思路有两种:

0786305bdb01bbbdcd93cad9c0130129.png

第二种结合了OAuth2体系,网关不仅仅承担流量转发功能,认证授权也是在网关层处理的,令牌会中继给下游服务。这种模式下需要搭建一个UAA(User Account And Authentication)服务。它非常灵活,它可以管理用户,也可以让受信任的客户端自己管理用户,它只负责对客户端进行认证(区别于用户认证)和对客户端进行授权。目前使用OAuth2对微服务进行安全体系建设的都使用这种方式。

44292b537f93ec5c3db3c873b181f731.png

oauth2分布式认证架构

image-20221003133911729

前后端分离架构设计

在这里插入图片描述

基本概念

oauth2包含以下角色:

四种模式

授权码模式(authorization_code)

需要的角色:资源所有者、应用程序、授权服务器、资源服务器。

需要的参数:授权码(需要用户认证并授权后获取)、应用的授信凭据

OAuth 2.0的一个基本授权流程。

实现步骤如下

  1. 用户在应用程序中,应用程序尝试获取用户保存在资源服务器上的信息,比如用户的身份信息和头像,应用程序首先让重定向用户到授权服务器,告知申请资源的读权限,并提供自己的client id。
  2. 到授权服务器,用户输入用户名和密码,服务器对其认证成功后,提示用户即将要颁发一个读权限给应用程序,在用户确认后,授权服务器颁发一个授权码(authorization code)并重定向用户回到应用程序。
  3. 应用程序获取到授权码之后,使用这个授权码和自己的client id/secret向认证服务器申请访问令牌/刷新令牌](access token/refresh token)。授权服务器对这些信息进行校验,如果一切OK,则颁发给应用程序访问令牌/刷新令牌。
  4. 应用程序在拿到访问令牌之后,向资源服务器申请用户的资源信息
  5. 资源服务器在获取到访问令牌后,对令牌进行解析(如果令牌已加密,则需要进行使用相应算法进行解密)并校验,并向授权服务器校验其合法性,如果一起OK,则返回应用程序所需要的资源信息。

img

简化模式、隐式(implicit)

需要的角色:(资源所有者、应用程序)、授权服务器、资源服务器。

需要的参数:应用client id、用户的授信凭据

应用程序运行在客户端,一个最大的变化就是其变成了公开应用程序(Public Client),应用程序的运行完全暴露在用户的控制之中。在这种场景下,应用程序是无法隐藏自己的一些敏感数据,比如client secret和授权码,在这个方式下,再向授权服务器获取授权码是多此一举。为此OAuth 2.0提供简化模式,授权服务器在校验好用户信息后,直接颁发给应用程序访问资源服务器的访问令牌。换句话说,应用程序在获取访问令牌时无需提供授权码和client secret。

实现步骤如下

  1. 用户在应用程序中,应用程序尝试获取用户保存在资源服务器上的信息,比如用户的身份信息和头像,应用程序首先让用户重定向到授权服务器,告知申请资源的读权限,并提供自己的client id。在重定向的过程中,应用程序指定使用Implicit Grant授权方式。
  2. 在授权服务器,用户输入用户名和密码,服务器对其认证成功后,提示用户即将要颁发一个读权限给应用程序,在用户确认后,授权服务器直接颁发一个访问令牌并重定向用户回到应用程序。
  3. 应用程序在拿到访问令牌之后,向资源服务器申请用户的资源信息
  4. 资源服务器在获取到访问令牌后,对令牌进行解析(如果令牌已加密,则需要进行使用相应算法进行解密)并校验,并向授权服务器校验其合法性,如果一起OK,则返回应用程序所需要的资源信息。

img

应用授信模式、客户端模式(client_credentials)

需要的角色:应用程序、授权服务器、资源服务器。

需要的参数:应用的授信凭据

这种模式的资源所有者角色不参与授权交互;应用程序角色本身就是资源所有者。

实现步骤如下

  1. 应用程序尝试获取在资源服务器上的信息,应用程序直接向授权服务器申请访问令牌,告知申请资源的读权限,并提供自己的授信凭证(client id/secret)。在申请请求中,应用程序指定使用client credentials授权方式。在授权服务器,服务器对其client credentials校验成功后,授权服务器直接颁发一个访问令牌给应用程序。
  2. 应用程序在拿到访问令牌之后,向资源服务器申请用户的资源信息
  3. 资源服务器在获取到访问令牌后,对令牌进行解析(如果令牌已加密,则需要进行使用相应算法进行解密)并校验,并向授权服务器校验其合法性,如果一起OK,则返回应用程序所需要的资源信息。

img

用户授信模式、密码式(password)

需要的角色:资源所有者、应用程序、授权服务器、资源服务器。

需要的参数:应用的授信凭据、用户的授信凭据

在基本的授权码模式中,用户需要跳转到授权服务器上,使用用户名和密码登录后拿到授权码,然后把授权码交给应用程序,然后再去申请访问令牌。但有些时候,能否省去这个来回的跳转过程,把用户名和密码直接交给应用程序,让应用程序去申请访问令牌。

实现步骤如下

  1. 用户在应用程序中,应用程序首先让用户到登录页面输入用户名和密码。
  2. 应用程序拿到资源所有者的用户名和密码,加上自己的client id/secret一同向认证服务器申请访问令牌/刷新令牌。授权服务器对这些信息进行校验,如果一切OK,则颁发给应用程序访问令牌/刷新令牌。
  3. 应用程序在拿到访问令牌/刷新令牌之后,向资源服务器申请用户的资源信息。
  4. 资源服务器在获取到访问令牌后,对令牌进行解析(如果令牌已加密,则需要进行使用相应算法进行解密)并校验,并向授权服务器校验其合法性,如果一起OK,则返回应用程序所需要的资源信息。

img

四种模式的选择

img

使用

1、新建UAA认证微服务,引入依赖

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

<!--   服务注册中心     -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

<!--    公共模块    -->
<dependency>
    <groupId>top.ygang</groupId>
    <artifactId>grady-young-common</artifactId>
    <version>1.0</version>
</dependency>

<!--    服务配置中心    -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>

<!-- spring-security依赖 -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-security</artifactId>
</dependency>

<!--oauth2-->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>

<!-- jwt依赖 -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
</dependency>

<!-- mybatis-plus依赖 -->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
</dependency>

<!-- 连接池,用于创建多个redis-template实例 -->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>

<!-- mysql依赖 -->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
</dependency>

<!-- druid数据库连接池 -->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
</dependency>

2、创建授权服务配置

@Configuration
@EnableAuthorizationServer
public class AuthorizationServer extends AuthorizationServerConfigurerAdapter {

    /**
     * 用来配置支持的客户端详情,在这里初始化客户端详情配置
     * @param clients
     * @throws Exception
     */
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
    }

    /**
     * 用来配置令牌访问端点和令牌服务,用来生成、颁发令牌
     * @param endpoints
     * @throws Exception
     */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
    }

    /**
     * 对于令牌端点的安全约束
     * @param security
     * @throws Exception
     */
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
    }
}

客户端详情配置(ClientDetailsServiceConfigurer),可以使用内存存储也可以使用JDBC数据库存储,ClientDetails重要的属性:

clientId:标识客户端ID

secret:客户端安全码(受信任的客户端才有)

scope:限制客户端访问范围,如果为空(默认),客户端可以访问所有资源

authorizedGrantTypes:客户端可以使用的授权类型

authorities:客户端的权限(基于spring security authorities)

/**
 * 用来配置支持的客户端详情,在这里初始化客户端详情配置
 * @param clients
 * @throws Exception
 */
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
    // 使用内存方式配置
    clients.inMemory()
        .withClient("test") //客户端id
        .secret("abcdef-abcdef")//客户端密钥
        .resourceIds("r1") //客户端可访问的资源列表
        .authorizedGrantTypes( //客户端可用来申请令牌的方式
        "authorization_code",
        "password",
        "client_credentials",
        "implicit",
        "refresh_token")
        .scopes("all") //允许访问的范围
        .autoApprove(false) //跳转到授权页面
        .redirectUris("https://www.baidu.com"); //验证回调地址
}

管理令牌服务(AuthorizationServerTokenServices),接口定义了一些操作可以对令牌进行管理

定义TokenConfig

@Configuration
public class TokenConfig {

    /**
     * 配置令牌存储策略
     * @return
     */
    @Bean
    public TokenStore tokenStore(){
        // 内存方式,生成普通令牌
        return new InMemoryTokenStore();
    }
}

在上面写的授权服务配置AuthorizationServer中,配置令牌服务

@Autowired
private ClientDetailsService clientDetailsService;

@Autowired
private TokenStore tokenStore;

/**
 * 配置令牌服务
 * @return
 */
@Bean
public AuthorizationServerTokenServices authorizationServerTokenServices(){
    DefaultTokenServices services = new DefaultTokenServices();
    services.setClientDetailsService(clientDetailsService); // 配置客户端信息服务
    services.setSupportRefreshToken(true); //是否产生刷新令牌
    services.setTokenStore(tokenStore); //配置令牌存储策略
    services.setAccessTokenValiditySeconds(7200); //令牌有效期
    services.setRefreshTokenValiditySeconds(259200); //刷新令牌有效期
    return services;
}

配置令牌访问端点(AuthorizationServerEndpointsConfigurer),这个配置对象有方法pathMapping()可以替换默认端点,第一个参数是要替换的默认端点,第二个参数是自定义端点。默认端点有:

/oauth/authorize:授权端点

/oauth/token:令牌端点

/oauth/confirm_access:用户确认授权提交端点

/oauth/error:授权服务错误信息端点

/oauth/check_token:用于资源服务进行令牌解析、校验的端点

/oauth/token_key:提供公有密钥的端点

/**
 * 暂时使用采用内存方式作为授权码存储
 * @return
 */
@Bean
public AuthorizationCodeServices authorizationCodeServices(){
    return new InMemoryAuthorizationCodeServices();
}

@Autowired
private AuthenticationManager authenticationManager;

@Autowired
private AuthorizationCodeServices authorizationCodeServices;

/**
 * 用来配置令牌访问端点和令牌服务,用来生成、颁发令牌
 * @param endpoints
 * @throws Exception
 */
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
    endpoints
        .authenticationManager(authenticationManager) // 用于密码模式或其他自定义的认证模式
        .authorizationCodeServices(authorizationCodeServices) // 授权码模式
        .tokenServices(authorizationServerTokenServices()) // 令牌管理服务
        .allowedTokenEndpointRequestMethods(HttpMethod.POST); // 允许post方式

}

令牌端点安全约束(AuthorizationServerSecurityConfigurer)

/**
 * 对于令牌端点的安全约束
 * @param security
 * @throws Exception
 */
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
    security
        .checkTokenAccess("permitAll()") //允许任何校验令牌的请求
        .tokenKeyAccess("permitAll()") //允许提供公钥
        .allowFormAuthenticationForClients(); //允许表单认证
}

3、配置SecurityConfig、userDetailsService,具体配置参考前面单体服务

3、简单测试各个模式

授权码模式(authorization_code)

授权码由第三方(认证服务器)颁发,可以使用授权码获取令牌,具体流程:

1、资源拥有者打开客户端,客户端要求资源拥有者授权,他将浏览器重定向到授权服务器,附带客户端信息

GET /oauth/authorize?client_id=test&response_type=code&scope=all&redirect_url=https://www.baidu.com

client_id:客户端id

response_type:授权模式,固定为code

scope:客户端权限

redirect_url:跳转url,申请授权码成功会跳转到此地址,后面会加上授权码code

2、认证账号密码

image-20221004151110397

3、登录成功,跳转授权页面,选择第一个Approve,进行授权

image-20221004151245222

4、跳转到之前的redirect_url,并且附带了code

image-20221004151423461

5、使用授权码申请令牌,一个授权码只能使用一次

POST /oauth/token
application/x-www-form-urlencoded
client_id 客户端id
client_secret 客户端密钥
grant_type 申请模式(授权码模式固定为authorization_code)
code 授权码
redirect_url 跳转地址,和申请授权码时一致

image-20221004152253137

简化模式(token)

和授权码模式第一步比较相似,只不过grant_type改为token,请求验证成功后,会直接将token以hash模式附带在redirect_url后面,请求地址:

GET /oauth/authorize?client_id=test&response_type=token&scope=all&redirect_url=https://www.baidu.com

得到结果:

image-20221004152814724

密码模式(password)

密码模式可能会泄漏账号密码给客户端,所以一般用在自己写的客户端

POST /oauth/token
application/x-www-form-urlencoded
client_id 客户端id
client_secret 客户端密钥
grant_type 申请模式(密码模式固定为password)
username 用户名
password 密码
redirect_url 跳转地址,和申请授权码时一致

image-20221004153405968

客户端模式(client_credentials)

客户端模式比密码模式还要简单,只需要客户端id、客户端密钥就可以申请token

POST /oauth/token
application/x-www-form-urlencoded
client_id 客户端id
client_secret 客户端密钥
grant_type 申请模式(客户端模式固定为client_credentials)
redirect_url 跳转地址,和申请授权码时一致

image-20221004153611519

集成jwt

1、在TokenConfig中配置JWT相关,使用对称加密,配置盐值

@Configuration
public class TokenConfig {
    
    /**
     * jwt令牌加密盐值
     */
    public static final String SIGNING_KEY = "sojff#OJSDF-9IOFH*124";

    /**
     * jwt令牌存储策略
     * @return
     */
    @Bean
    public JwtTokenStore jwtTokenStore(){
        return new JwtTokenStore(jwtAccessTokenConverter());
    }

    /**
     * jwt令牌生成策略
     * @return
     */
    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter(){
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setSigningKey(SIGNING_KEY);
        return converter;
    }
}

2、在AuthorizationServer中配置令牌增强,使用jwt令牌,使用新的JwtTokenStore,替换之前的TokenStore

/**
 * 配置令牌服务
 * @return
 */
@Bean
public AuthorizationServerTokenServices authorizationServerTokenServices(){
    DefaultTokenServices services = new DefaultTokenServices();
    services.setClientDetailsService(clientDetailsService); // 配置客户端信息服务
    services.setSupportRefreshToken(true); //是否产生刷新令牌
    services.setTokenStore(jwtTokenStore); //配置令牌存储策略
    // 配置令牌增强,使用jwt
    TokenEnhancerChain chain = new TokenEnhancerChain();
    chain.setTokenEnhancers(Arrays.asList(jwtAccessTokenConverter));
    services.setTokenEnhancer(chain);

    services.setAccessTokenValiditySeconds(7200); //令牌有效期
    services.setRefreshTokenValiditySeconds(259200); //刷新令牌有效期
    return services;
}

3、重新请求令牌,发现令牌已经成功使用jwt进行生成

image-20221004160420506

4、校验令牌,发现用户信息已经存储

image-20221004160436613

oauth2数据持久化

OAuth2.0的服务端和资源端都不是我们自己写的,都是springsecurity框架给我们写的,既然是springsecurity框架的,那么客户端的信息保存在数据库里面的时候,这个数据库的表结构就需要使用springsecurity框架定义的。

DROP TABLE IF EXISTS `oauth_client_details`;

CREATE TABLE `oauth_client_details` (
  `client_id` varchar(255) NOT NULL COMMENT '客户端id',
  `resource_ids` varchar(255) DEFAULT NULL COMMENT '客户端所能访问的资源id集合',
  `client_secret` varchar(255) DEFAULT NULL COMMENT '用于指定客户端(client)的访问密匙',
  `scope` varchar(255) DEFAULT NULL COMMENT '指定客户端申请的权限范围',
  `authorized_grant_types` varchar(255) DEFAULT NULL COMMENT '指定客户端支持的grant_type',
  `web_server_redirect_uri` varchar(255) DEFAULT NULL COMMENT '客户端的重定向URI,',
  `authorities` varchar(255) DEFAULT NULL COMMENT '指定客户端所拥有的Spring Security的权限值',
  `access_token_validity` int(11) DEFAULT NULL COMMENT '设定客户端的access_token的有效时间值(单位:秒),可选,默认的有效时间值12小时',
  `refresh_token_validity` int(11) DEFAULT NULL COMMENT '设定客户端的refresh_token的有效时间值',
  `additional_information` varchar(255) DEFAULT NULL COMMENT '预留的字段',
  `autoapprove` varchar(255) DEFAULT NULL COMMENT '设置用户是否自动Approval操作'
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

DROP TABLE IF EXISTS `oauth_access_token`;

CREATE TABLE `oauth_access_token` (
  `token_id` varchar(255) DEFAULT NULL,
  `token` longblob,
  `authentication_id` varchar(255) DEFAULT NULL,
  `user_name` varchar(255) DEFAULT NULL,
  `client_id` varchar(255) DEFAULT NULL,
  `authentication` longblob,
  `refresh_token` varchar(255) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

DROP TABLE IF EXISTS `oauth_approvals`;

CREATE TABLE `oauth_approvals` (
  `userId` varchar(255) DEFAULT NULL,
  `clientId` varchar(255) DEFAULT NULL,
  `scope` varchar(255) DEFAULT NULL,
  `status` varchar(10) DEFAULT NULL,
  `expiresAt` datetime DEFAULT NULL,
  `lastModifiedAt` datetime DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

DROP TABLE IF EXISTS `oauth_client_token`;

CREATE TABLE `oauth_client_token` (
  `token_id` varchar(255) DEFAULT NULL,
  `token` longblob,
  `authentication_id` varchar(255) DEFAULT NULL,
  `user_name` varchar(255) DEFAULT NULL,
  `client_id` varchar(255) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

DROP TABLE IF EXISTS `oauth_code`;

CREATE TABLE `oauth_code` (
  `code` varchar(255) DEFAULT NULL,
  `authentication` varbinary(2550) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

DROP TABLE IF EXISTS `oauth_refresh_token`;

CREATE TABLE `oauth_refresh_token` (
  `token_id` varchar(255) DEFAULT NULL,
  `token` longblob,
  `authentication` longblob
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

对于之前存在内存中的信息改为存储到数据库

@Autowired
private DataSource dataSource;

/**
 * 配置使用数据库作为授权码存储方式
 * @return
 */
@Bean
public AuthorizationCodeServices authorizationCodeServices(){
    return new JdbcAuthorizationCodeServices(dataSource);
}

/**
 * 配置从数据库获得客户端详情
 * @return
 */
@Bean
public ClientDetailsService clientDetailsService(){
    JdbcClientDetailsService jdbcClientDetailsService = new JdbcClientDetailsService(dataSource);
    jdbcClientDetailsService.setPasswordEncoder(passwordEncoder);
    return jdbcClientDetailsService;
}