spring security OAuth2 实战

目录

1、OAuth 介绍

(1)OAuth 2.0授权流程

(2)授权模式分类

2、OAuth2的授权码模式

(1)相关依赖引入

(2)配置 spring security 

(3)添加授权服务器

(4)添加资源服务器

3、OAuth2的简化模式

4、OAuth2的密码模式

5、OAuth2的客户端模式

6、更新令牌

7、基于 redis 存储 Token


1、OAuth 介绍

OAuth(Open Authorization)是一个关于授权(authorization)的开放网络标准,允许用户授权第三方应用访问存储在另外的服务提供者上的信息,而不需要将用户名和密码提供给第三方应用或分享他们数据的所有内容。OAuth在全世界得到广泛应用,目前的版本是2.0版。

OAuth协议:https://tools.ietf.org/html/rfc6749

OAuth 基本概念

  • Third-party application:第三方应用程序,又称"客户端"(client),比如京东商城
  • HTTP service:HTTP服务提供商,简称"服务提供商",比如微信
  • Resource Owner:资源所有者,又称"用户"(user),登陆用户的信息
  • User Agent:用户代理,比如浏览器。
  • Authorization server:授权服务器,即服务提供商专门用来处理授权的服务器。
  • Resource server:资源服务器,即服务提供商存放用户生成的资源的服务器。它与认证服务器,可以是同一台服务器,也可以是不同的服务器。

OAuth的作用就是让"客户端"安全可控地获取"用户"的授权,与"服务商提供商"进行交互。

(1)OAuth 2.0授权流程

以微信开放平台为例:准备工作 | 微信开放文档

基本设计思想:OAuth 引入了一个授权层,用来分离两种不同的角色:客户端和资源所有者。资源所有者同意以后,资源服务器可以向客户端颁发令牌。客户端通过令牌,去请求数据。

授权流程图示:

步骤明细:

  1. 第三方发起微信授权登录请求,微信用户允许授权第三方应用后,微信会拉起应用或重定向到第三方网站,并且带上授权临时票据 code 参数;
  2. 通过 code 参数加上 AppID 和 AppSecret 等,通过 API 换取 access_token;// 微信方生成token
  3. 通过access_token进行接口调用,获取用户基本数据资源或帮助用户实现基本操作。

(2)授权模式分类

客户端必须得到用户的授权(authorization grant),才能获得令牌(access token)。OAuth 2.0 对于如何颁发令牌有四种方式:

  1. 授权码模式(authorization code)
  2. 密码模式(resource owner password credentials)
  3. 简化(隐式)模式(implicit)
  4. 客户端模式(client credentials)

不论哪一种授权方式,第三方应用申请令牌之前,都必须先到授权系统备案,注册自己的身份,然后会获取两个身份识别码:客户端 ID(client ID)和客户端密钥(client secret)。

2、OAuth2的授权码模式

授权码(authorization code)方式,指第三方应用向授权服务方先申请授权码,然后用授权码获取令牌。授权码模式是最常用的授权流程,安全性高,适用于有后端的 Web 应用。授权码虽然通过前端传送,但令牌储存在后端,并且所有与资源服务器的通信都由后端完成,对前端屏蔽,从而可以避免令牌泄漏。

 步骤总结:

// 1-获取授权码
http://localhost:8080/oauth/authorize?response_type=code&client_id=clientA&redirect_uri=http://www.baidu.com&scope=all

// 2-用户登陆后,返回授权码
https://www.baidu.com/?code=ru2Ye2

// 3-通过授权码获取token

请求服务方的提供授权码——>用户授权——>服务方返回授权码——>通过授权码获取 access_token ——> 通过 access_token 访问资源

(1)相关依赖引入

首先需要引入security 和 oauth2 的相关依赖,spring boot 依赖引入示例:

<!-- Spring Security 配置 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>        
<dependency>
    <groupId>org.springframework.security.oauth</groupId>
    <artifactId>spring-security-oauth2</artifactId>
    <version>2.3.4.RELEASE</version>
</dependency>

如果是微服务,对应的 spring cloud 依赖引入示例(security 也要引入):

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

(2)配置 spring security 

WebSecurityConfigurerAdapter 是自定义的 spring security 配置文件,在  spring security 基本功能实现的基础上,配置请求拦截规则,要求对所有的 /oauth/** 接口放行 

import com.swadian.userdemo.service.MyUserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration // 标记为注解类
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
    @Autowired
    @Lazy // 解决循环依赖
    private MyUserDetailsService userService;

    @Bean // 编码
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override // 注入用户信息
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        //设置UserDetailsService的实现类
        auth.userDetailsService(userService);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin().permitAll() // 表单登陆
                .and()
                .authorizeRequests()
                .antMatchers("/oauth/**").permitAll() // 授权oauth下的所有接口
                .anyRequest().authenticated()
                .and().logout().permitAll() // 登出
                .and().csrf().disable();
    }
}

配置用户信息是权限系统的基本要求,需要根据用户信息进行身份验证,授权校验

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

@Service
public class MyUserDetailsService implements UserDetailsService {

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        String password = passwordEncoder.encode("123456"); // 密码
        return new User("admin",
                password,
                AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
    }
}

模拟需要授权的接口,一旦接口请求成功,会返回一串字符串,示例如下:

@RestController
@RequestMapping("/admin")
public class AdminController {
    @GetMapping("/test")
    public String test() {
        return "Spring Security Test";
    }
}

(3)添加授权服务器

授权服务器具有以下核心端点

添加授权服务器配置,@EnableAuthorizationServer 开启授权服务器,注册可用的客户端,并在授权模式中添加 authorization_code ,表示授权码模式

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.password.PasswordEncoder;
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;

@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                .withClient("clientA") //配置客户端id client_id
                .secret(passwordEncoder.encode("123456")) //配置client-secret
                .accessTokenValiditySeconds(3600) //配置访问token的有效期
                .refreshTokenValiditySeconds(864000) //配置刷新token的有效期
                .redirectUris("http://www.baidu.com") //配置redirect_uri,授权成功后跳转
                .scopes("all")  //配置申请的权限范围
                .authorizedGrantTypes("authorization_code");//配置grant_type,表示授权类型
    }
}

(4)添加资源服务器

配置资源服务器,明确进行授权的资源

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;

@Configuration
@EnableResourceServer
public class ResourceServiceConfig extends ResourceServerConfigurerAdapter {
    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .requestMatchers().antMatchers("/admin/**"); // 需要验权资源
    }
}

测试流程

启动 SpringBoot 项目,在浏览器端请求授权服务器进行授权,输入以下访问链接

http://localhost:8080/oauth/authorize?response_type=code&client_id=clientA&redirect_uri=http://www.baidu.com&scope=all

http://localhost:8080/oauth/authorize?response_type=code&client_id=clientA&redirect_uri=http://www.baidu.com&scope=all

授权服务器需要进行登陆,输入用户名和密码进行登陆

用户登陆成功后,跳转到授权页面

点击 Approve 进行授权确认,确认后会携带一个code,跳转到重定向地址

接下来使用code(授权码),获取登陆令牌,使用 postman 进行模拟请求,授权服务器获取令牌的接口为

http://127.0.0.1:8080/oauth/token

 参数 grant_type:authorization_code,redirect_uri:百度(随便填的,必填),code:上步骤返回的code

请求成功后,会返回 json 格式的数据,其中就有我们需要的令牌

{
    "access_token": "3c26e355-a011-4b0f-a6f6-91a724fd0153",
    "token_type": "bearer",
    "expires_in": 3599,
    "scope": "all"
}
  • grant_type :授权类型,authorization_code,表示授权码模式 
  • code :授权码,刚刚获取的code,注意:授权码只能使用一次,后续需要重新申请 
  • client_id :客户端标识 
  • redirect_uri :跳转url,一定要和申请授权码时用的redirect_uri一致 
  • scope :授权范围

如果认证失败,服务端会返回 401 Unauthorized

接下来,使用令牌(access_token)去资源服务器访问授权资源,资源能够正常访问

或者,直接在资源后携带 access_token

或者,在请求头中,添加 Authorization 授权信息,注意要带上 token 类型 bearer

至此,spring security OAuth2 的授权码模式完成

3、OAuth2的简化模式

简化(隐式)模式,允许直接向前端颁发令牌,这种方式没有授权码这个中间步骤,所以称为(授权码)"隐藏式"(implicit)。该模式直接在浏览器中向授权服务器申请令牌,没有"授权码"步骤,所有步骤都在浏览器中完成,同时令牌对访问者也是可见的。

简化模式直接把令牌传给前端,该方式很不安全。因此,只能用于一些安全性要求不高的场景,并且令牌设置的有效期必须非常短,通常只是会话期间(session)有效,一旦浏览器关掉,令牌就就失效了。

使用场景:纯前端应用

步骤总结:

请求服务提供方的授权 ——>直接获取 access_token ——> 携带 access_token 访问资源

代码示例:

在上文代码的基础上,修改授权服务器的配置,在 authorizedGrantTypes 属性中添加 "implicit", //简化模式,完整的代码如下:

    // 完整的代码如下
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                .withClient("clientA") //配置client_id
                .secret(passwordEncoder.encode("123456")) //配置client-secret
                .accessTokenValiditySeconds(3600) //配置访问token的有效期
                .refreshTokenValiditySeconds(864000) //配置刷新token的有效期
                .redirectUris("http://www.baidu.com") //配置redirect_uri,用于授权成功后跳转
                .scopes("all")  //配置申请的权限范围
                .authorizedGrantTypes(
                        //"authorization_code", //授权码模式,配置grant_type,表示授权类型
                        "implicit", //简化模式
                        //"password", //密码模式
                        //"client_credentials",//客户端模式
                        "refresh_token"); //更新令牌
    }

启动服务,访问以下链接

http://localhost:8080/oauth/authorize?response_type=token&client_id=clientA&redirect_uri=http://www.baidu.com&scope=all

http://localhost:8080/oauth/authorize?response_type=token&client_id=clientA&redirect_uri=http://www.baidu.com&scope=all

将会直接跳转到重定向页面,同时,access_token 会在跳转的地址栏中返回,中间不需要经过任何环节。

4、OAuth2的密码模式

如果对某个应用高度信任,用户直接使用用户名和密码申请令牌,这种方式称为"密码式"(password)。

在这种模式中,用户必须把密码给客户端,但是客户端不得储存密码。通常在用户对客户端高度信任的场景下使用,比如客户端是操作系统的一部分。一般来说授权服务器只有在其他授权模式无法执行的情况下,才考虑使用这种模式。

适用场景:公司自己搭建的授权服务器

代码示例:

在上文代码的基础上,修改 spring security 配置,在配置中增加 authenticationManagerBean

import com.swadian.userdemo.service.MyUserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration // 标记为注解类
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {

    @Autowired
    @Lazy // 解决循环依赖
    private MyUserDetailsService userService;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        //设置UserDetailsService的实现类
        auth.userDetailsService(userService);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin().permitAll()
                .and()
                .authorizeRequests().antMatchers("/oauth/**").permitAll()
                .anyRequest().authenticated()
                .and().logout().permitAll()
                .and().csrf().disable();
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

然后修改授权服务器的配置,在 endpoints 配之中,添加允许使用 GET 和 POST 请求,并且允许进行表单验证,完整代码如下:

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.crypto.password.PasswordEncoder;
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;

@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
    @Autowired
    private PasswordEncoder passwordEncoder;

    @Autowired
    private AuthenticationManager authenticationManagerBean;

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.authenticationManager(authenticationManagerBean) //使用密码模式需要配置
                .allowedTokenEndpointRequestMethods(HttpMethod.GET, HttpMethod.POST); //支持 GET,POST请求
    }

    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        //允许表单认证
        security.allowFormAuthenticationForClients();
    }

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                .withClient("clientA") //配置client_id
                .secret(passwordEncoder.encode("123456")) //配置client-secret
                .accessTokenValiditySeconds(3600) //配置访问token的有效期
                .refreshTokenValiditySeconds(864000) //配置刷新token的有效期
                .redirectUris("http://www.baidu.com") //配置redirect_uri,用于授权成功后跳转
                .scopes("all")  //配置申请的权限范围
                .authorizedGrantTypes(//"authorization_code", //授权码模式  //配置grant_type,表示授权类型
                        "password", //密码模式
                        //"client_credentials",//客户端模式
                        "refresh_token"); //更新令牌
    }
}

接下来获取令牌

测试方法一:通过浏览器访问以下地址 // 需要配置支持get请求和表单验证

http://localhost:8080/oauth/token?username=admin&password=123456&grant_type=password&client_id=clientA&client_secret=123456&scope=all

http://localhost:8080/oauth/token?username=admin&password=123456&grant_type=password&client_id=clientA&client_secret=123456&scope=all

访问结果如下:// 直接获取到 access_token

测试方法二:通过 postman 进行测试 

尝试访问授权资源

5、OAuth2的客户端模式

客户端模式(Client Credentials Grant)指客户端以服务器身份,向服务提供方申请授权。适用于没有前端的命令行应用,一般提供给受信任的服务器端使用。

在上文代码的基础上,配置 grant_type 为 client_credentials,然后直接在浏览器中输入以下链接就进行验证

http://localhost:8080/oauth/token?grant_type=client_credentials&client_id=clientA&scope=all&client_secret=123456

http://localhost:8080/oauth/token?grant_type=client_credentials&client_id=clientA&scope=all&client_secret=123456

验证结果如下:

6、更新令牌

使用 oauth2 时,如果令牌失效了,可以通过 refresh_token 的授权模式再次获取access_token,从而刷新令牌,刷新令牌可避免繁琐的重复认证

在上边代码的基础上修改授权服务器配置,在 authorizedGrantTypes 中添加  refresh_token 类型,同时可以在 endpoints 配置中,配置 refresh_token 是否可重复使用

@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
    @Autowired
    private PasswordEncoder passwordEncoder;

    @Autowired
    private MyUserDetailsService userService;

    @Autowired
    private AuthenticationManager authenticationManagerBean;

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.authenticationManager(authenticationManagerBean) //使用密码模式需要配置
                .reuseRefreshTokens(false) //refresh_token是否重复使用
                .userDetailsService(userService) //刷新令牌授权包含对用户信息的检查
                .allowedTokenEndpointRequestMethods(HttpMethod.GET, HttpMethod.POST); //支持 GET,POST请求
    }

    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        //允许表单认证
        security.allowFormAuthenticationForClients();
    }

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                .withClient("clientA") //配置client_id
                .secret(passwordEncoder.encode("123456")) //配置client-secret
                .accessTokenValiditySeconds(3600) //配置访问token的有效期
                .refreshTokenValiditySeconds(864000) //配置刷新token的有效期
                .redirectUris("http://www.baidu.com") //配置redirect_uri,用于授权成功后跳转
                .scopes("all")  //配置申请的权限范围
                .authorizedGrantTypes(//"authorization_code", //授权码模式  //配置grant_type,表示授权类型
                        "password", //密码模式
                        //"client_credentials",//客户端模式
                        "refresh_token"); //更新令牌
    }
}

通过密码模式进行测试,在浏览器端访问以下链接

http://localhost:8080/oauth/token
?username=admin
&password=123456
&grant_type=password
&client_id=clientA
&client_secret=123456
&scope=all

http://localhost:8080/oauth/token?username=admin&password=123456&grant_type=password&client_id=clientA&client_secret=123456&scope=all

在返回报文中,有一个 refresh_token 的字段,如果需要刷新令牌,可以使用 refresh_token 访问如下链接:

http://localhost:8080/oauth/token?grant_type=refresh_token&client_id=clientA&client_secret=123456&refresh_token=73ca27c6-69b9-4788-ab8a-edee28d0ec93

http://localhost:8080/oauth/token?grant_type=refresh_token&client_id=clientA&client_secret=123456&refresh_token=73ca27c6-69b9-4788-ab8a-edee28d0ec93

7、基于 redis 存储 Token

需要引入 redis 相关的依赖

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

修改配置文件 application.yaml ,添加 redis 配置

spring:
  redis:
    host: localhost
    port: 6379
    database: 0

 编写 redis 配置类,返回 RedisTokenStore 的存储实例

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.redis.RedisTokenStore;

@Configuration
public class RedisConfig {

    @Autowired
    private RedisConnectionFactory redisConnectionFactory;

    @Bean
    public TokenStore tokenStore() {
        return new RedisTokenStore(redisConnectionFactory);
    }
}

在授权服务器配置中指定令牌的存储策略为 Redis

@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
    @Autowired
    private PasswordEncoder passwordEncoder;

    @Autowired
    private TokenStore tokenStore;

    @Autowired
    private MyUserDetailsService userService;

    @Autowired
    private AuthenticationManager authenticationManagerBean;

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.authenticationManager(authenticationManagerBean) //使用密码模式需要配置
                .tokenStore(tokenStore) // 指定token存储到redis
                .reuseRefreshTokens(false) //refresh_token是否重复使用
                .userDetailsService(userService) //刷新令牌授权包含对用户信息的检查
                .allowedTokenEndpointRequestMethods(HttpMethod.GET, HttpMethod.POST); //支持 GET,POST请求
    }
}

接下来,使用密码模式进行测试,访问如下链接

http://localhost:8080/oauth/token?username=admin&password=123456&grant_type=password&client_id=clientA&client_secret=123456&scope=all

查看下 redis 是否存储了 token

至此,redis  存储 Token 验证成功