1049 lines
45 KiB
Markdown
1049 lines
45 KiB
Markdown
|
||
|
||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||
|
||
|
||
11 实战案例:使用Spring Security搭建一套基于JWT的OAuth 2.0架构
|
||
《OAuth 2.0 实战课》上线之后,我也第一时间关注了这门课。在开篇词中,我看到有一些同学留言问道:“如何使用 Spring Security 来实现 OAuth 2.0?”这时,我想到之前自己写过一篇相关的文章,于是就直接在开篇词下留了言。后面我很快收到了不少用户的点赞和肯定,紧接着极客时间编辑也邀请我从自己的角度为专栏写篇加餐。好吧,功不唐捐,于是我就将之前我写的那篇老文章再次迭代、整理为今天的这一讲内容,希望可以帮助你掌握 OAuth 2.0。
|
||
|
||
如果你熟悉 Spring Security 的话,肯定知道它因为功能多、组件抽象程度高、配置方式多样,导致了强大且复杂的特性。也因此,Spring Security 的学习成本几乎是 Spring 家族中最高的。但不仅于此,在结合实际的复杂业务场景使用 Spring Security 时,我们还要去理解一些组件的工作原理和流程,不然需要自定义和扩展框架的时候就会手足无措。这就让使用 Spring Security 的门槛更高了。
|
||
|
||
因此,在决定使用 Spring Security 搭建整套安全体系(授权、认证、权限、审计)之前,我们还需要考虑的是:将来我们的业务会多复杂,徒手写一套安全体系来得划算,还是使用 Spring Security 更好?我相信,这也是王老师给出课程配套代码中,并没有使用 Spring Security 来演示 OAuth 2.0 流程的原因之一。
|
||
|
||
反过来说,如果你的应用已经使用了 Spring Security 来做鉴权、认证和权限管理的话,那么仍然使用 Spring Security 来实现 OAuth 的成本是很低的。而且,在学习了 OAuth 2.0 的流程打下扎实的基础之后,我们再使用 Spring Security 来配置 OAuth 2.0 就不会那么迷茫了。这也是我在工作中使用 Spring Security 来实现 OAuth 2.0 的直观感受。
|
||
|
||
所以,我就结合自己的实践和积累,带你使用 Spring Security 来一步一步地搭建一套基于 JWT 的 OAuth 2.0 授权体系。这些内容会涉及 OAuth 2.0 的三角色(客户端、授权服务、受保护资源),以及资源拥有者凭据许可、客户端凭据许可和授权码许可这三种常用的授权许可类型(隐式许可类型,不太安全也不太常用)。同时,我还会演示 OAuth 2.0 的权限控制,以及使用 OAuth 2.0 实现 SSO 单点登录体系。
|
||
|
||
这样一来,今天这一讲涉及到的流程就会比较多,内容也会很长。不过不用担心,我会手把手带你从零开始,完成整个程序的搭建,并给出所有流程的演示。
|
||
|
||
项目准备工作
|
||
|
||
实战之前,我们先来搭建项目父依赖和初始化数据库结构,为后面具体的编码做准备。
|
||
|
||
首先,我们来创建一个父 POM,内含三个模块:
|
||
|
||
springsecurity101-cloud-oauth2-client,用来扮演客户端角色;
|
||
springsecurity101-cloud-oauth2-server,用来扮演授权服务器角色;
|
||
springsecurity101-cloud-oauth2-userservice,是用户服务,用来扮演资源提供者角色。
|
||
|
||
|
||
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||
xmlns="http://maven.apache.org/POM/4.0.0"
|
||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||
<modelVersion>4.0.0</modelVersion>
|
||
<groupId>me.josephzhu</groupId>
|
||
<artifactId>springsecurity101</artifactId>
|
||
<packaging>pom</packaging>
|
||
<version>1.0-SNAPSHOT</version>
|
||
|
||
<parent>
|
||
<groupId>org.springframework.boot</groupId>
|
||
<artifactId>spring-boot-starter-parent</artifactId>
|
||
<version>2.2.1.RELEASE</version>
|
||
<relativePath/>
|
||
</parent>
|
||
|
||
<modules>
|
||
<module>springsecurity101-cloud-oauth2-client</module>
|
||
<module>springsecurity101-cloud-oauth2-server</module>
|
||
<module>springsecurity101-cloud-oauth2-userservice</module>
|
||
</modules>
|
||
|
||
<properties>
|
||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||
<java.version>1.8</java.version>
|
||
</properties>
|
||
|
||
<dependencies>
|
||
<dependency>
|
||
<groupId>org.projectlombok</groupId>
|
||
<artifactId>lombok</artifactId>
|
||
<optional>true</optional>
|
||
</dependency>
|
||
</dependencies>
|
||
|
||
<dependencyManagement>
|
||
<dependencies>
|
||
<dependency>
|
||
<groupId>org.springframework.cloud</groupId>
|
||
<artifactId>spring-cloud-dependencies</artifactId>
|
||
<version>Greenwich.SR4</version>
|
||
<type>pom</type>
|
||
<scope>import</scope>
|
||
</dependency>
|
||
</dependencies>
|
||
</dependencyManagement>
|
||
|
||
<build>
|
||
<plugins>
|
||
<plugin>
|
||
<groupId>org.springframework.boot</groupId>
|
||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||
</plugin>
|
||
</plugins>
|
||
</build>
|
||
</project>
|
||
|
||
|
||
然后,我们来创建一个 oauth 数据库,初始化将来会用到的 5 个表。
|
||
|
||
authorities 表:记录账号的权限,需要我们在后面配置。
|
||
|
||
oauth_approvals 表:记录授权批准的状态。
|
||
|
||
oauth_client_details 表:记录 OAuth 的客户端,需要我们在后面做配置。
|
||
|
||
oauth_code 表:记录授权码。
|
||
|
||
users 表:记录账号,需要我们在后面做初始化。
|
||
|
||
SET NAMES utf8mb4;
|
||
SET FOREIGN_KEY_CHECKS = 0;
|
||
|
||
DROP TABLE IF EXISTS `authorities`;
|
||
CREATE TABLE `authorities` (
|
||
`username` varchar(50) NOT NULL,
|
||
`authority` varchar(50) NOT NULL,
|
||
UNIQUE KEY `ix_auth_username` (`username`,`authority`),
|
||
CONSTRAINT `fk_authorities_users` FOREIGN KEY (`username`) REFERENCES `users` (`username`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||
|
||
DROP TABLE IF EXISTS `oauth_approvals`;
|
||
CREATE TABLE `oauth_approvals` (
|
||
`userId` varchar(256) DEFAULT NULL,
|
||
`clientId` varchar(256) DEFAULT NULL,
|
||
`partnerKey` varchar(32) DEFAULT NULL,
|
||
`scope` varchar(256) 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_details`;
|
||
CREATE TABLE `oauth_client_details` (
|
||
`client_id` varchar(255) NOT NULL,
|
||
`resource_ids` varchar(255) DEFAULT NULL,
|
||
`client_secret` varchar(255) DEFAULT NULL,
|
||
`scope` varchar(255) DEFAULT NULL,
|
||
`authorized_grant_types` varchar(255) DEFAULT NULL,
|
||
`web_server_redirect_uri` varchar(255) DEFAULT NULL,
|
||
`authorities` varchar(255) DEFAULT NULL,
|
||
`access_token_validity` int(11) DEFAULT NULL,
|
||
`refresh_token_validity` int(11) DEFAULT NULL,
|
||
`additional_information` varchar(4096) DEFAULT NULL,
|
||
`autoapprove` varchar(255) DEFAULT NULL,
|
||
PRIMARY KEY (`client_id`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||
|
||
DROP TABLE IF EXISTS `oauth_code`;
|
||
CREATE TABLE `oauth_code` (
|
||
`code` varchar(255) DEFAULT NULL,
|
||
`authentication` blob
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||
|
||
DROP TABLE IF EXISTS `users`;
|
||
CREATE TABLE `users` (
|
||
`username` varchar(50) NOT NULL,
|
||
`password` varchar(100) NOT NULL,
|
||
`enabled` tinyint(1) NOT NULL,
|
||
PRIMARY KEY (`username`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||
SET FOREIGN_KEY_CHECKS = 1;
|
||
|
||
|
||
这 5 个表是 Spring Security OAuth 需要用到的存储表,我们不要去修改既有的表结构。这里可以看到,我们并没有在数据库中创建相应的表,来存放访问令牌和刷新令牌。这是因为,我们之后的实现会使用 JWT 来传输令牌信息,以便进行本地校验,所以并不一定要将其存放到数据库中。基本上所有的这些表都是可以自己扩展的,只需要继承实现 Spring 的一些既有类即可,这里不做展开。
|
||
|
||
接下来,我们开始搭建授权服务器和受保护资源服务器。
|
||
|
||
搭建授权服务器
|
||
|
||
我们先创建第一个模块,也就是授权服务器。首先创建 POM,配置依赖:
|
||
|
||
<?xml version="1.0" encoding="UTF-8"?>
|
||
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||
xmlns="http://maven.apache.org/POM/4.0.0"
|
||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||
|
||
<parent>
|
||
<artifactId>springsecurity101</artifactId>
|
||
<groupId>me.josephzhu</groupId>
|
||
<version>1.0-SNAPSHOT</version>
|
||
</parent>
|
||
|
||
<modelVersion>4.0.0</modelVersion>
|
||
<artifactId>springsecurity101-cloud-oauth2-server</artifactId>
|
||
|
||
<dependencies>
|
||
<dependency>
|
||
<groupId>org.springframework.cloud</groupId>
|
||
<artifactId>spring-cloud-starter-oauth2</artifactId>
|
||
</dependency>
|
||
<dependency>
|
||
<groupId>org.springframework.boot</groupId>
|
||
<artifactId>spring-boot-starter-jdbc</artifactId>
|
||
</dependency>
|
||
<dependency>
|
||
<groupId>mysql</groupId>
|
||
<artifactId>mysql-connector-java</artifactId>
|
||
</dependency>
|
||
<dependency>
|
||
<groupId>org.springframework.boot</groupId>
|
||
<artifactId>spring-boot-starter-web</artifactId>
|
||
</dependency>
|
||
<dependency>
|
||
<groupId>org.springframework.boot</groupId>
|
||
<artifactId>spring-boot-starter-thymeleaf</artifactId>
|
||
</dependency>
|
||
</dependencies>
|
||
</project>
|
||
|
||
|
||
这里,我们使用了 Spring Cloud 的 spring-cloud-starter-oauth2 组件,而不是直接使用的 Spring Security,因为前者做了一些自动化配置的工作,使用起来会更方便。
|
||
|
||
此外,我们还在 POM 中加入了数据访问、Web 等依赖,因为我们的受保护资源服务器需要使用数据库来保存客户端的信息、用户信息等数据,同时也会引入 thymeleaf 模板引擎依赖,来稍稍美化一下登录页面。
|
||
|
||
然后创建一个配置文件 application.yml 实现程序配置:
|
||
|
||
server:
|
||
port: 8080
|
||
spring:
|
||
application:
|
||
name: oauth2-server
|
||
datasource:
|
||
url: jdbc:mysql://localhost:6657/oauth?useSSL=false
|
||
username: root
|
||
password: kIo9u7Oi0eg
|
||
driver-class-name: com.mysql.jdbc.Driver
|
||
|
||
|
||
可以看到,我们配置了 oauth 数据库的连接字符串,定义了授权服务器的监听端口是 8080。
|
||
|
||
最后,使用 keytool 工具生成密钥对,把密钥文件 jks 保存到资源目录下,并要导出一个公钥留作以后使用。
|
||
|
||
以上完成了项目框架搭建工作,接下来,我们正式开始编码。
|
||
|
||
第一步,创建一个最核心的类用于配置授权服务器。我把每段代码的作用放在了注释里,你可以直接看下。
|
||
|
||
@Configuration
|
||
@EnableAuthorizationServer
|
||
public class OAuth2ServerConfiguration extends AuthorizationServerConfigurerAdapter {
|
||
@Autowired
|
||
private DataSource dataSource;
|
||
@Autowired
|
||
private AuthenticationManager authenticationManager;
|
||
|
||
|
||
* 我们配置了使用数据库来维护客户端信息。虽然在各种Demo中我们经常看到的是在内存中维护客户端信息,通过配置直接写死在这里。
|
||
|
||
* 但是,对于实际的应用我们一般都会用数据库来维护这个信息,甚至还会建立一套工作流来允许客户端自己申请ClientID,实现OAuth客户端接入的审批。
|
||
|
||
/*
|
||
* @param clients
|
||
* @throws Exception
|
||
*/
|
||
@Override
|
||
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
|
||
clients.jdbc(dataSource);
|
||
}
|
||
|
||
/*
|
||
* 这里干了两件事儿。首先,打开了验证Token的访问权限(以便之后我们演示)。
|
||
* 然后,允许ClientSecret明文方式保存,并且可以通过表单提交(而不仅仅是Basic Auth方式提交),之后会演示到这个。
|
||
* @param security
|
||
* @throws Exception
|
||
*/
|
||
@Override
|
||
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
|
||
security.checkTokenAccess("permitAll()").allowFormAuthenticationForClients().passwordEncoder(NoOpPasswordEncoder.getInstance());
|
||
}
|
||
|
||
/*
|
||
* 干了以下4件事儿:
|
||
* 1. 配置我们的令牌存放方式为JWT方式,而不是内存、数据库或Redis方式。
|
||
* JWT是Json Web Token的缩写,也就是使用JSON数据格式包装的令牌,由.号把整个JWT分隔为头、数据体、签名三部分。
|
||
* JWT保存Token虽然易于使用但是不是那么安全,一般用于内部,且需要走HTTPS并配置比较短的失效时间。
|
||
* 2. 配置JWT Token的非对称加密来进行签名
|
||
* 3. 配置一个自定义的Token增强器,把更多信息放入Token中
|
||
* 4. 配置使用JDBC数据库方式来保存用户的授权批准记录
|
||
* @param endpoints
|
||
* @throws Exception
|
||
*/
|
||
@Override
|
||
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
|
||
TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
|
||
tokenEnhancerChain.setTokenEnhancers(
|
||
Arrays.asList(tokenEnhancer(), jwtTokenEnhancer()));
|
||
endpoints.approvalStore(approvalStore())
|
||
.authorizationCodeServices(authorizationCodeServices())
|
||
.tokenStore(tokenStore())
|
||
.tokenEnhancer(tokenEnhancerChain)
|
||
.authenticationManager(authenticationManager);
|
||
}
|
||
|
||
/*
|
||
* 使用JDBC数据库方式来保存授权码
|
||
* @return
|
||
*/
|
||
@Bean
|
||
public AuthorizationCodeServices authorizationCodeServices() {
|
||
return new JdbcAuthorizationCodeServices(dataSource);
|
||
}
|
||
|
||
/*
|
||
* 使用JWT存储
|
||
* @return
|
||
*/
|
||
@Bean
|
||
public TokenStore tokenStore() {
|
||
return new JwtTokenStore(jwtTokenEnhancer());
|
||
}
|
||
|
||
/*
|
||
* 使用JDBC数据库方式来保存用户的授权批准记录
|
||
* @return
|
||
*/
|
||
@Bean
|
||
public JdbcApprovalStore approvalStore() {
|
||
return new JdbcApprovalStore(dataSource);
|
||
}
|
||
|
||
/*
|
||
* 自定义的Token增强器,把更多信息放入Token中
|
||
* @return
|
||
*/
|
||
@Bean
|
||
public TokenEnhancer tokenEnhancer() {
|
||
return new CustomTokenEnhancer();
|
||
}
|
||
|
||
/*
|
||
* 配置JWT使用非对称加密方式来验证
|
||
* @return
|
||
*/
|
||
@Bean
|
||
protected JwtAccessTokenConverter jwtTokenEnhancer() {
|
||
KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("jwt.jks"), "mySecretKey".toCharArray());
|
||
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
|
||
converter.setKeyPair(keyStoreKeyFactory.getKeyPair("jwt"));
|
||
return converter;
|
||
}
|
||
|
||
/*
|
||
* 配置登录页面的视图信息(其实可以独立一个配置类,这样会更规范)
|
||
*/
|
||
@Configuration
|
||
static class MvcConfig implements WebMvcConfigurer {
|
||
@Override
|
||
public void addViewControllers(ViewControllerRegistry registry) {
|
||
registry.addViewController("login").setViewName("login");
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
第二步,还记得吗,刚才在第一步的代码中我们还用到了一个自定义的 Token 增强器,把用户信息嵌入到 JWT Token 中去(如果使用的是客户端凭据许可类型,这段代码无效,因为和用户没关系)。
|
||
|
||
这是一个常见需求。因为,默认情况下 Token 中只会有用户名这样的基本信息,我们往往需要把关于用户的更多信息返回给客户端(在实际应用中,你可能会从数据库或外部服务查询更多的用户信息加入到 JWT Token 中去)。这个时候,我们就可以自定义增强器来丰富 Token 的内容:
|
||
|
||
public class CustomTokenEnhancer implements TokenEnhancer {
|
||
@Override
|
||
public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
|
||
|
||
Authentication userAuthentication = authentication.getUserAuthentication();
|
||
if (userAuthentication != null) {
|
||
Object principal = authentication.getUserAuthentication().getPrincipal();
|
||
Map<String, Object> additionalInfo = new HashMap<>();
|
||
additionalInfo.put("userDetails", principal);
|
||
((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo);
|
||
}
|
||
return accessToken;
|
||
}
|
||
}
|
||
|
||
|
||
第三步,实现安全方面的配置。你可以直接看下代码注释,来了解关键代码的作用。
|
||
|
||
@Configuration
|
||
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
|
||
@Autowired
|
||
private DataSource dataSource;
|
||
|
||
@Override
|
||
@Bean
|
||
public AuthenticationManager authenticationManagerBean() throws Exception {
|
||
return super.authenticationManagerBean();
|
||
}
|
||
|
||
/*
|
||
* 配置用户账户的认证方式。显然,我们把用户存在了数据库中希望配置JDBC的方式。
|
||
* 此外,我们还配置了使用BCryptPasswordEncoder哈希来保存用户的密码(生产环境中,用户密码肯定不能是明文保存的)
|
||
|
||
* @param auth
|
||
* @throws Exception
|
||
*/
|
||
@Override
|
||
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
|
||
auth.jdbcAuthentication()
|
||
.dataSource(dataSource)
|
||
.passwordEncoder(new BCryptPasswordEncoder());
|
||
}
|
||
|
||
/*
|
||
* 开放/login和/oauth/authorize两个路径的匿名访问。前者用于登录,后者用于换授权码,这两个端点访问的时机都在登录之前。
|
||
* 设置/login使用表单验证进行登录。
|
||
* @param http
|
||
* @throws Exception
|
||
*/
|
||
@Override
|
||
protected void configure(HttpSecurity http) throws Exception {
|
||
http.authorizeRequests()
|
||
.antMatchers("/login", "/oauth/authorize")
|
||
.permitAll()
|
||
.anyRequest().authenticated()
|
||
.and()
|
||
.formLogin().loginPage("/login");
|
||
}
|
||
}
|
||
|
||
|
||
第四步,在资源目录下创建一个 templates 文件夹,然后创建一个 login.html 登录页:
|
||
|
||
<body class="uk-height-1-1">
|
||
<div class="uk-vertical-align uk-text-center uk-height-1-1">
|
||
<div class="uk-vertical-align-middle" style="width: 250px;">
|
||
<h1>Login Form</h1>
|
||
<p class="uk-text-danger" th:if="${param.error}">
|
||
用户名或密码错误...
|
||
</p>
|
||
<form class="uk-panel uk-panel-box uk-form" method="post" th:action="@{/login}">
|
||
<div class="uk-form-row">
|
||
<input class="uk-width-1-1 uk-form-large" type="text" placeholder="Username" name="username"
|
||
value="reader"/>
|
||
</div>
|
||
<div class="uk-form-row">
|
||
<input class="uk-width-1-1 uk-form-large" type="password" placeholder="Password" name="password"
|
||
value="reader"/>
|
||
</div>
|
||
<div class="uk-form-row">
|
||
<button class="uk-width-1-1 uk-button uk-button-primary uk-button-large">Login</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
|
||
|
||
至此,授权服务器的编码工作就完成了。
|
||
|
||
搭建受保护资源服务器
|
||
|
||
接下来,我们搭建一个用户服务模拟资源提供者(受保护资源服务器)。我们先看看项目初始化工作。
|
||
|
||
这次创建的 POM 没有什么特殊,依赖了 spring-cloud-starter-oauth2:
|
||
|
||
<?xml version="1.0" encoding="UTF-8"?>
|
||
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||
xmlns="http://maven.apache.org/POM/4.0.0"
|
||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||
|
||
<parent>
|
||
<artifactId>springsecurity101</artifactId>
|
||
<groupId>me.josephzhu</groupId>
|
||
<version>1.0-SNAPSHOT</version>
|
||
</parent>
|
||
|
||
<modelVersion>4.0.0</modelVersion>
|
||
<artifactId>springsecurity101-cloud-oauth2-userservice</artifactId>
|
||
|
||
<dependencies>
|
||
<dependency>
|
||
<groupId>org.springframework.cloud</groupId>
|
||
<artifactId>spring-cloud-starter-oauth2</artifactId>
|
||
</dependency>
|
||
<dependency>
|
||
<groupId>org.springframework.boot</groupId>
|
||
<artifactId>spring-boot-starter-web</artifactId>
|
||
</dependency>
|
||
</dependencies>
|
||
</project>
|
||
|
||
|
||
配置文件非常简单,只是声明了资源服务端口为 8081:
|
||
|
||
同时,还要记得把我们之前在项目准备工作时生成的密钥对的公钥命名为 public.cert,并放到资源文件下。这样,资源服务器可以本地校验 JWT 的合法性。内容大概是这样的:
|
||
|
||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwR84LFHwnK5GXErnwkmD
|
||
|
||
mPOJl4CSTtYXCqmCtlbF+5qVOosu0YsM2DsrC9O2gun6wVFKkWYiMoBSjsNMSI3Z
|
||
|
||
w5JYgh+ldHvA+MIex2QXfOZx920M1fPUiuUPgmnTFS+Z3lmK3/T6jJnmciUPY1pe
|
||
|
||
h4MXL6YzeI0q4W9xNBBeKT6FDGpduc0FC3OlXHfLbVOThKmAUpAWFDwf9/uUA//l
|
||
|
||
3PLchmV6VwTcUaaHp5W8Af/GU4lPGZbTAqOxzB9ukisPFuO1DikacPhrOQgdxtqk
|
||
|
||
LciRTa884uQnkFwSguOEUYf3ni8GNRJauIuW0rVXhMOs78pKvCKmo53M0tqeC6ul
|
||
|
||
+QIDAQAB
|
||
|
||
|
||
好了,让我们正式开始编码吧。
|
||
|
||
第一步,创建一个可以匿名访问的接口 GET /hello,用来测试无需登录就可以访问的服务端资源:
|
||
|
||
@RestController
|
||
public class HelloController {
|
||
@GetMapping("hello")
|
||
public String hello() {
|
||
return "Hello";
|
||
}
|
||
}
|
||
|
||
|
||
第二步,创建三个需要登录 + 授权才能访问到的接口。我们通过 @PreAuthorize 在方法执行前进行权限控制:
|
||
|
||
GET /user/name 接口,读权限或写权限可访问,返回登录用户名;
|
||
|
||
GET /user 接口,读权限或写权限可访问,返回登录用户信息;
|
||
|
||
POST /user 接口,只有写权限可以访问,返回访问令牌中的额外信息(也就是自定义的 Token 增强器 CustomTokenEnhancer 加入到访问令牌中的额外信息,Key 是 userDetails),这里也演示了使用 TokenStore 来解析 Token 的方式。
|
||
|
||
@RestController
|
||
@RequestMapping("user")
|
||
public class UserController {
|
||
@Autowired
|
||
private TokenStore tokenStore;
|
||
/*
|
||
* 读权限或写权限可访问,返回登录用户名
|
||
* @param authentication
|
||
* @return
|
||
*/
|
||
@PreAuthorize("hasAuthority('READ') or hasAuthority('WRITE')")
|
||
@GetMapping("name")
|
||
public String name(OAuth2Authentication authentication) {
|
||
return authentication.getName();
|
||
}
|
||
/*
|
||
* 读权限或写权限可访问,返回登录用户信息
|
||
* @param authentication
|
||
* @return
|
||
*/
|
||
|
||
@PreAuthorize("hasAuthority('READ') or hasAuthority('WRITE')")
|
||
@GetMapping
|
||
public OAuth2Authentication read(OAuth2Authentication authentication) {
|
||
return authentication;
|
||
}
|
||
/*
|
||
* 只有写权限可以访问,返回访问令牌中的额外信息
|
||
* @param authentication
|
||
* @return
|
||
*/
|
||
@PreAuthorize("hasAuthority('WRITE')")
|
||
@PostMapping
|
||
public Object write(OAuth2Authentication authentication) {
|
||
OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails) authentication.getDetails();
|
||
OAuth2AccessToken accessToken = tokenStore.readAccessToken(details.getTokenValue());
|
||
return accessToken.getAdditionalInformation().getOrDefault("userDetails", null);
|
||
}
|
||
}
|
||
|
||
|
||
第三步,创建核心的资源服务器配置类。这里我们需要注意下面两点:
|
||
|
||
我们硬编码了资源服务器的 ID 为 userservice;
|
||
|
||
现在我们使用的是不落数据库的 JWT 方式 + 非对称加密,需要通过本地公钥进行验证,因此在这里我们配置了公钥的路径。
|
||
|
||
@Configuration
|
||
@EnableResourceServer
|
||
@EnableGlobalMethodSecurity(prePostEnabled = true)
|
||
public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {
|
||
/*
|
||
* 声明了资源服务器的ID是userservice,声明了资源服务器的TokenStore是JWT
|
||
* @param resources
|
||
* @throws Exception
|
||
*/
|
||
@Override
|
||
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
|
||
resources.resourceId("userservice").tokenStore(tokenStore());
|
||
}
|
||
/*
|
||
* 配置TokenStore
|
||
* @return
|
||
*/
|
||
@Bean
|
||
public TokenStore tokenStore() {
|
||
return new JwtTokenStore(jwtAccessTokenConverter());
|
||
}
|
||
/*
|
||
* 配置公钥
|
||
* @return
|
||
*/
|
||
@Bean
|
||
protected JwtAccessTokenConverter jwtAccessTokenConverter() {
|
||
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
|
||
Resource resource = new ClassPathResource("public.cert");
|
||
String publicKey = null;
|
||
try {
|
||
publicKey = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()));
|
||
} catch (IOException e) {
|
||
e.printStackTrace();
|
||
}
|
||
converter.setVerifierKey(publicKey);
|
||
return converter;
|
||
}
|
||
/*
|
||
* 配置了除了/user路径之外的请求可以匿名访问
|
||
* @param http
|
||
* @throws Exception
|
||
*/
|
||
@Override
|
||
public void configure(HttpSecurity http) throws Exception {
|
||
http.authorizeRequests()
|
||
.antMatchers("/user/**").authenticated()
|
||
.anyRequest().permitAll();
|
||
}
|
||
}
|
||
|
||
|
||
到这里,我们来想一下,如果授权服务器产生 Token 的话,受保护资源服务器必须要有一种办法来验证 Token,那如果这里的 Token 不是 JWT 的方式,我们可以怎么办呢?
|
||
|
||
我来说下我的方法吧:
|
||
|
||
首先,Token 可以保存在数据库或 Redis 中,资源服务器和授权服务器共享底层的 TokenStore 来验证;
|
||
|
||
然后,资源服务器可以使用 RemoteTokenServices,来从授权服务器的 /oauth/check_token 端点进行 Token 校验。
|
||
|
||
到这里,资源服务器就配置完成了,我们还在资源服务器中分别创建了两个控制器 HelloController 和 UserController,用于分别测试可以匿名访问以及受到权限保护的资源。
|
||
|
||
初始化数据配置
|
||
|
||
在实现了授权服务器和受保护资源服务器代码后,我们再来初始化 oauth 数据库的数据就非常容易理解了。总结起来,我们需要配置用户、权限和客户端三部分。
|
||
|
||
配置两个用户。其中,读用户 reader 具有读权限,密码为 reader;写用户 writer 具有读写权限,密码为 writer。还记得吗,密码我们使用的是 BCryptPasswordEncoder 加密(准确说是哈希)?
|
||
|
||
INSERT INTO `users` VALUES ('reader', '$2a$04$C6pPJvC1v6.enW6ZZxX.luTdpSI/1gcgTVN7LhvQV6l/AfmzNU/3i', 1);
|
||
|
||
INSERT INTO `users` VALUES ('writer', '$2a$04$M9t2oVs3/VIreBMocOujqOaB/oziWL0SnlWdt8hV4YnlhQrORA0fS', 1);
|
||
|
||
|
||
配置两个权限,也就是配置 reader 用户具有读权限,writer 用户具有写权限:
|
||
|
||
INSERT INTO `authorities` VALUES ('reader', 'READ');
|
||
|
||
INSERT INTO `authorities` VALUES ('writer', 'READ,WRITE');
|
||
|
||
|
||
配置三个客户端,其中客户端 userservice1 使用资源拥有者凭据许可类型,客户端 userservice2 使用客户端凭据许可类型,客户端 userservice3 使用授权码许可类型。
|
||
|
||
INSERT INTO `oauth_client_details` VALUES ('userservice1', 'userservice', '1234', 'FOO', 'password,refresh_token', '', 'READ,WRITE', 7200, NULL, NULL, 'true');
|
||
|
||
INSERT INTO `oauth_client_details` VALUES ('userservice2', 'userservice', '1234', 'FOO', 'client_credentials,refresh_token', '', 'READ,WRITE', 7200, NULL, NULL, 'true');
|
||
|
||
INSERT INTO `oauth_client_details` VALUES ('userservice3', 'userservice', '1234', 'FOO', 'authorization_code,refresh_token', 'https://baidu.com,http://localhost:8082/ui/login,http://localhost:8083/ui/login,http://localhost:8082/ui/remoteCall', 'READ,WRITE', 7200, NULL, NULL, 'false');
|
||
|
||
|
||
值得说明的是:
|
||
|
||
三个客户端账号能使用的资源 ID 都是 userservice,对应我们受保护资源服务器刚才配置的资源 ID,也就是 userservice,这两者需要一致。
|
||
|
||
三个客户端账号的密码都是 1234。
|
||
|
||
三个客户端账号的授权范围都是 FOO(并不是关键信息),它们可以拿到的权限是读写。不过,对于和用户相关的授权许可类型(比如资源拥有者凭据许可、授权码许可),最终拿到的权限还取决于客户端权限和用户权限的交集。
|
||
|
||
通过 grant_types 字段配置支持不同的授权许可类型。这里为了便于测试观察,我们给三个客户端账号各自配置了一种授权许可类型;在实际业务场景中,你完全可以为同一个客户端配置支持 OAuth 2.0 的四种授权许可类型。
|
||
|
||
userservice1 和 userservice2 我们配置了用户自动批准授权(不会弹出一个页面要求用户进行授权)。
|
||
|
||
演示三种授权许可类型
|
||
|
||
到这里,授权服务器和受保护资源服务器程序都搭建完成了,数据库也配置了用于测试的用户、权限和客户端。接下来,我们就使用 Postman 来手工测试一下 OAuth 2.0 的授权码许可、资源拥有者凭据许可、客户端凭据许可这三种授权许可类型吧。
|
||
|
||
资源拥有者凭据许可类型
|
||
|
||
首先,我们测试的是资源拥有者凭据许可,POST 请求地址是:
|
||
|
||
http://localhost:8080/oauth/token?grant_type=password&client_id=userservice1&client_secret=1234&username=writer&password=writer
|
||
|
||
|
||
得到如下图所示结果:
|
||
|
||
|
||
|
||
|
||
|
||
可以看到,Token 中果然包含了 Token 增强器加入的 userDetails 自定义信息。如果我们把公钥粘贴到页面的话,可以看到这个 JWT 校验成功了:
|
||
|
||
|
||
|
||
除了本地校验外,还可以访问授权服务器来校验 JWT:
|
||
|
||
http://localhost:8080/oauth/check_token?client_id=userservice1&client_secret=1234&token=...
|
||
|
||
|
||
得到如下结果:
|
||
|
||
|
||
|
||
客户端授权许可类型
|
||
|
||
我们再来测试下客户端授权许可类型。POST 请求地址:
|
||
|
||
http://localhost:8080/oauth/token?grant_type=client_credentials&client_id=userservice2&client_secret=1234
|
||
|
||
|
||
如下图所示,可以直接拿到 Token:
|
||
|
||
|
||
|
||
这里需要注意的是,并没有提供刷新令牌。这是因为,刷新令牌用于避免访问令牌失效后需要用户再次登录的问题,而客户端授权许可类型没有用户的概念,因此没有刷新令牌,也无法注入额外的 userDetails 信息。
|
||
|
||
|
||
|
||
也可以试一下,如果我们的授权服务器没有开启 allowFormAuthenticationForClients 参数(允许表单提交认证)的话,客户端的凭证需要通过 Basic Auth 传过去而不是通过 Post:
|
||
|
||
|
||
|
||
授权码许可类型
|
||
|
||
最后,我们来测试下比较复杂的授权码许可。
|
||
|
||
第一步,打开浏览器访问地址:
|
||
|
||
http://localhost:8080/oauth/authorize?response_type=code&client_id=userservice3&redirect_uri=https://baidu.com
|
||
|
||
|
||
我们之前已经在数据库中有配置了)。访问后页面会直接跳转到登录界面,我们使用用户名“reader”、密码“reader”来登录:
|
||
|
||
|
||
|
||
由于我们在数据库中设置的是禁用自动批准授权的模式,所以登录后来到了批准界面:
|
||
|
||
|
||
|
||
点击同意后可以看到,数据库中也会产生授权通过记录:
|
||
|
||
|
||
|
||
第二步,我们可以看到浏览器转到了百度并且提供给了我们授权码:
|
||
|
||
数据库中也记录了授权码:
|
||
|
||
|
||
|
||
然后 POST 访问下面的地址(code 参数替换为刚才获得的授权码):
|
||
|
||
http://localhost:8080/oauth/token?grant_type=authorization_code&client_id=userservice3&client_secret=1234&code=XKkHGY&redirect_uri=https://baidu.com
|
||
|
||
|
||
可以通过授权码换取访问令牌:
|
||
|
||
|
||
|
||
虽然 userservice3 客户端可以有读权限和写权限,但是因为我们登录的用户 reader 只有读权限,所以最后拿到也只有读权限。
|
||
|
||
演示权限控制
|
||
|
||
现在我们来测试一下之前定义的两个账号,也就是读账号和写账号,看看它们的权限控制是否有效。
|
||
|
||
首先,测试一下我们的安全配置,访问 /hello 端点不需要认证可以匿名访问:
|
||
|
||
|
||
|
||
访问 /user 需要身份认证:
|
||
|
||
|
||
|
||
不管以哪种模式拿到访问令牌,我们用具有读权限的访问令牌访问资源服务器的如下地址
|
||
|
||
(请求头加入 Authorization: Bearer XXXXXXXXXX,其中 XXXXXXXXXX 代表访问令牌):
|
||
|
||
http://localhost:8081/user/
|
||
|
||
|
||
可以得到如下结果:
|
||
|
||
|
||
|
||
以 POST 方式访问 http://localhost:8081/user/,显然是失败的:
|
||
|
||
|
||
|
||
因为这个接口要求有写权限:
|
||
|
||
@PreAuthorize("hasAuthority('WRITE')")
|
||
@PostMapping
|
||
public Object write(OAuth2Authentication authentication) {
|
||
|
||
|
||
我们换一个具有读写权限的访问令牌来试试:
|
||
|
||
|
||
|
||
可以发现,果然访问成功了。这里输出的内容是 Token 中的 userDetails 额外信息,说明资源服务器的权限控制有效。
|
||
|
||
搭建客户端程序
|
||
|
||
在上面的演示中,我们使用的是 Postman,也就是手动 HTTP 请求的方式来申请和使用 Token。最后,我们来搭建一个 OAuth 客户端程序自动实现这个过程。
|
||
|
||
<?xml version="1.0" encoding="UTF-8"?>
|
||
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||
xmlns="http://maven.apache.org/POM/4.0.0"
|
||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||
|
||
<parent>
|
||
<artifactId>springsecurity101</artifactId>
|
||
<groupId>me.josephzhu</groupId>
|
||
<version>1.0-SNAPSHOT</version>
|
||
</parent>
|
||
|
||
<artifactId>springsecurity101-cloud-oauth2-client</artifactId>
|
||
<modelVersion>4.0.0</modelVersion>
|
||
|
||
<dependencies>
|
||
<dependency>
|
||
<groupId>org.springframework.cloud</groupId>
|
||
<artifactId>spring-cloud-starter-oauth2</artifactId>
|
||
</dependency>
|
||
<dependency>
|
||
<groupId>org.springframework.boot</groupId>
|
||
<artifactId>spring-boot-starter-web</artifactId>
|
||
</dependency>
|
||
<dependency>
|
||
<groupId>org.springframework.boot</groupId>
|
||
<artifactId>spring-boot-starter-thymeleaf</artifactId>
|
||
</dependency>
|
||
</dependencies>
|
||
</project>
|
||
|
||
|
||
配置文件如下:
|
||
|
||
server:
|
||
port: 8083
|
||
servlet:
|
||
context-path: /ui
|
||
security:
|
||
oauth2:
|
||
client:
|
||
clientId: userservice3
|
||
clientSecret: 1234
|
||
accessTokenUri: http://localhost:8080/oauth/token
|
||
userAuthorizationUri: http://localhost:8080/oauth/authorize
|
||
scope: FOO
|
||
resource:
|
||
jwt:
|
||
key-value: |
|
||
-----BEGIN PUBLIC KEY-----
|
||
***
|
||
-----END PUBLIC KEY-----
|
||
spring:
|
||
thymeleaf:
|
||
cache: false
|
||
|
||
|
||
客户端项目端口 8082,几个需要说明的地方:
|
||
|
||
本地测试的时候有一个坑,也就是我们需要配置 context-path,否则可能会出现客户端和授权服务器服务端 Cookie 干扰,导致 CSRF 防御触发的问题。这个问题出现后程序没有任何错误日志输出,只有开启 DEBUG 模式后才能看到 DEBUG 日志里有提示,因此这个问题非常难以排查。说实话,我也不知道 Spring 为什么不把这个信息作为 WARN 级别的日志输出。
|
||
|
||
作为 OAuth 客户端,我们需要配置 OAuth 服务端获取 Token 的地址、授权(获取授权码)的地址,需要配置客户端的 ID、密码和授权范围。
|
||
|
||
因为使用的是 JWT Token,我们需要配置公钥(当然,如果不在这里直接配置公钥的话,也可以配置从授权服务器服务端获取公钥)。
|
||
|
||
接下来,我们可以开始编码了。
|
||
|
||
第一步,实现 MVC 的配置:
|
||
|
||
@Configuration
|
||
@EnableWebMvc
|
||
public class WebMvcConfig implements WebMvcConfigurer {
|
||
/*
|
||
* 配置RequestContextListener用于启用session scope的Bean
|
||
* @return
|
||
*/
|
||
@Bean
|
||
public RequestContextListener requestContextListener() {
|
||
return new RequestContextListener();
|
||
}
|
||
/*
|
||
* 配置index路径的首页Controller
|
||
* @param registry
|
||
*/
|
||
@Override
|
||
public void addViewControllers(ViewControllerRegistry registry) {
|
||
registry.addViewController("/")
|
||
.setViewName("forward:/index");
|
||
registry.addViewController("/index");
|
||
}
|
||
}
|
||
|
||
|
||
这里做了两件事情:
|
||
|
||
配置 RequestContextListener,用于启用 session scope 的 Bean;
|
||
|
||
配置了 index 路径的首页 Controller。
|
||
|
||
第二步,实现安全方面的配置:
|
||
|
||
@Configuration
|
||
@Order(200)
|
||
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
|
||
/*
|
||
* /路径和/login路径允许访问,其它路径需要身份认证后才能访问
|
||
* @param http
|
||
* @throws Exception
|
||
*/
|
||
@Override
|
||
protected void configure(HttpSecurity http) throws Exception {
|
||
http
|
||
.authorizeRequests()
|
||
.antMatchers("/", "/login**")
|
||
.permitAll()
|
||
.anyRequest()
|
||
.authenticated();
|
||
}
|
||
}
|
||
|
||
|
||
这里我们实现的是 / 路径和 /login 路径允许访问,其它路径需要身份认证后才能访问。
|
||
|
||
第三步,我们来创建一个控制器:
|
||
|
||
@RestController
|
||
public class DemoController {
|
||
@Autowired
|
||
OAuth2RestTemplate restTemplate;
|
||
@GetMapping("/securedPage")
|
||
public ModelAndView securedPage(OAuth2Authentication authentication) {
|
||
return new ModelAndView("securedPage").addObject("authentication", authentication);
|
||
}
|
||
|
||
@GetMapping("/remoteCall")
|
||
public String remoteCall() {
|
||
ResponseEntity<String> responseEntity = restTemplate.getForEntity("http://localhost:8081/user/name", String.class);
|
||
return responseEntity.getBody();
|
||
}
|
||
}
|
||
|
||
|
||
这里我们实现了两个功能:
|
||
|
||
securedPage 页面,实现的功能是,把用户信息作为模型传入了视图,这样打开页面后就能显示用户名和权限。
|
||
|
||
remoteCall 接口,实现的功能是,通过引入 OAuth2RestTemplate,在登录后就可以使用凭据直接从受保护资源服务器拿资源,不需要繁琐地实现获得访问令牌、在请求头里加入访问令牌的过程。
|
||
|
||
第四步,配置一下刚才用到的 OAuth2RestTemplate Bean,并启用 OAuth2Sso 功能:
|
||
|
||
@Configuration
|
||
@EnableOAuth2Sso
|
||
public class OAuthClientConfig {
|
||
/*
|
||
* 定义了OAuth2RestTemplate,网上一些比较老的资料给出的是手动读取配置文件来实现,最新版本已经可以自动注入OAuth2ProtectedResourceDetails
|
||
* @param oAuth2ClientContext
|
||
* @param details
|
||
* @return
|
||
*/
|
||
@Bean
|
||
public OAuth2RestTemplate oauth2RestTemplate(OAuth2ClientContext oAuth2ClientContext,
|
||
OAuth2ProtectedResourceDetails details) {
|
||
return new OAuth2RestTemplate(details, oAuth2ClientContext);
|
||
}
|
||
}
|
||
|
||
|
||
第五步,实现首页:
|
||
|
||
<body>
|
||
<div class="container">
|
||
<div class="col-sm-12">
|
||
<h1>Spring Security SSO Client</h1>
|
||
<a class="btn btn-primary" href="securedPage">Login</a>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
|
||
|
||
以及登录后才能访问的 securedPage 页面:
|
||
|
||
<body>
|
||
<div class="container">
|
||
<div class="col-sm-12">
|
||
<h1>Secured Page</h1>
|
||
Welcome, <span th:text="${authentication.name}">Name</span>
|
||
<br />
|
||
Your authorities are <span th:text="${authentication.authorities}">authorities</span>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
|
||
|
||
演示单点登录
|
||
|
||
好,客户端程序搭建好之后,我们先来测试一下单点登录的功能。启动客户端项目,打开浏览器访问:
|
||
|
||
http://localhost:8082/ui/securedPage
|
||
|
||
|
||
可以看到,页面自动转到了授权服务器(8080 端口)的登录页面:
|
||
|
||
|
||
|
||
登录后显示了当前用户名和权限:
|
||
|
||
|
||
|
||
我们再启动另一个客户端网站,端口改为 8083,然后访问同样的地址:
|
||
|
||
|
||
|
||
可以看到直接是登录状态,单点登录测试成功。是不是很方便?其实,为了达成单点登录的效果,程序在背后自动实现了多次 302 重定向,整个流程为:
|
||
|
||
http://localhost:8083/ui/securedPage ->
|
||
|
||
http://localhost:8083/ui/login ->
|
||
|
||
http://localhost:8080/oauth/authorize?client_id=userservice3&redirect_uri=http://localhost:8083/ui/login&response_type=code&scope=FOO&state=Sobjqe ->
|
||
|
||
http://localhost:8083/ui/login?code=CDdvHa&state=Sobjqe ->
|
||
|
||
http://localhost:8083/ui/securedPage
|
||
|
||
|
||
演示客户端请求资源服务器资源
|
||
|
||
还记得吗,在上一节“搭建客户端程序”中,我们还定义了一个 remoteCall 接口,直接使用 OAuth2RestTemplate 来访问远程资源服务器的资源。现在,我们来测试一下这个接口是否可以实现自动的 OAuth 流程。访问:
|
||
|
||
http://localhost:8082/ui/remoteCall
|
||
|
||
|
||
会先转到授权服务器登录,登录后自动跳转回来:
|
||
|
||
|
||
|
||
可以看到输出了用户名,对应的资源服务器服务端接口是:
|
||
|
||
@PreAuthorize("hasAuthority('READ') or hasAuthority('WRITE')")
|
||
@GetMapping("name")
|
||
public String name(OAuth2Authentication authentication) {
|
||
return authentication.getName();
|
||
}
|
||
|
||
|
||
换一个 writer 用户登录试试,也能得到正确的输出:
|
||
|
||
|
||
|
||
总结
|
||
|
||
今天这一讲,我们完整演示了如何使用 Spring Cloud 的 OAuth 2.0 组件基于三个程序角色(授权服务器、受保护资源服务器和客户端)实现三种 OAuth 2.0 的授权许可类型(资源拥有者凭据许可、客户端凭据许可和授权码许可)。
|
||
|
||
我们先演示了三种授权许可类型的手动流程,然后也演示了如何实现权限控制和单点登录,以及如何使用客户端程序来实现自动的 OAuth 2.0 流程。
|
||
|
||
我把今天用到的所有代码都放到了 GitHub 上,你可以点击这个链接查看。
|
||
|
||
|
||
|
||
|