写在前面

我们知道分布式系统由多个不同的子系统组成,但是我们希望在使用系统的时只需登录一次就能访问该系统,而不用多次登录,因此单点登录是一个很常见的需求。

在《OAuth2.0+JWT实现单点登录》一文中,我们使用OAuth2+JWT这一技术实现了单点登录,其实正确来说我们实现的是无状态登录,只是这种无状态登录满足单点登录的要求。

前面也说了无状态存在一些缺点,因此接下来我们尝试使用另一种技术,使用SpringBoot+OAuth2.0并结合@EnableOAuth2Sso注解这一方式来实现单点登录。

请注意上述两种实现方案都有其比较适合的场景,因此具体选择哪种需要结合实际情况来进行选择。

初始化项目

前面我们都是将授权服务器和资源服务器分开搭建,本篇出于简单考虑,就直接将两者放在一个服务器上,名称统一为“统一认证中心”。此案例除了授权服务器外,还需要多个客户端应用,这里就提供两个。各实例项目名称、角色名称和端口如下表所示:

项目名称 角色名称 端口
auth-server 授权服务器+资源服务器 2019
client-app1 客户端1 2020
client-app2 客户端2 2021

可以看到此处的auth-server项目充当授权服务器和资源服务器的角色,client-app1client-app2项目分别扮演子系统角色,之后当用户从client-app1上登录成功后,此时也能访问到client-app2,这样我们就能验证单点登录功能配置成功了。

空Maven父工程搭建

使用Maven新建一个空白的父工程,名称为oauth2-sso,之后我们将在这个父工程中搭建子项目。

统一认证中心搭建

oauth2-sso父工程中新建一个子模块,名称为auth-server,在选择依赖的时候选择如下三个依赖:Web、Spring Cloud Security下的Cloud Security和Cloud OAuth2:

第一步,将父工程oauth2-sso项目的pom.xml依赖文件修改为如下所示配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?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">
<modelVersion>4.0.0</modelVersion>

<groupId>com.envy</groupId>
<artifactId>oauth2-sso</artifactId>
<version>1.0-SNAPSHOT</version>
<name>oauth2-sso</name>
<description>SpringBoot+OAuth2实现单点登录</description>
<packaging>pom</packaging>

<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>

<modules>
<module>auth-server</module>
</modules>
</project>

第二步,由于此项目需要充当授权服务器和资源服务器的角色,因此需要在这个项目的启动类上添加@EnableResourceServer注解,表示开启资源服务器配置:

1
2
3
4
5
6
7
@SpringBootApplication
@EnableResourceServer
public class AuthServerApplication {
public static void main(String[] args) {
SpringApplication.run(AuthServerApplication.class, args);
}
}

第三步,接下来我们开始对授权服务器进行配置,由于此处的资源服务器和授权服务器放在一起,因此对于授权服务器的配置非常简单。新建一个config包,并在该包内新建一个AuthServerConfig类,注意它需要继承AuthorizationServerConfigurerAdapter类,里面的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Configuration
@EnableAuthorizationServer
public class AuthServerConfig extends AuthorizationServerConfigurerAdapter {
@Autowired
private PasswordEncoder passwordEncoder;

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory().withClient("envythink")
.secret(passwordEncoder.encode("1234"))
.autoApprove(true)
.redirectUris("http://127.0.0.1:2020/login","http://127.0.0.1:2021/login")
.scopes("user")
.accessTokenValiditySeconds(7200)
.authorizedGrantTypes("authorization_code");
}
}

可以看到此处我们只是简单的配置了客户端的信息,且配置的非常简单,直接将客户端信息保存在内存中。
第四步,在config包内新建一个SecurityConfig类,注意它需要继承WebSecurityConfigurerAdapter类,里面的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@Configuration
@Order(1)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}

@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/login.html","/css/**","/js/**","/images/**");
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http.requestMatchers()
.antMatchers("/login")
.antMatchers("/oauth/authorize")
.and()
.authorizeRequests().anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login.html")
.loginProcessingUrl("/login")
.permitAll()
.and()
.csrf().disable();
}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("envy")
.password(passwordEncoder().encode("1234"))
.roles("admin");
}
}

可以看到我们首先提供了一个返回BCryptPasswordEncoder实例的方法,之后重写了configure(WebSecurity web)方法,该方法用户放开对静态资源的访问权限。接着重写了configure(HttpSecurity http)方法,在该方法中我们对认证相关的端点进行放行,同时自定义了登录页面和登录接口。然后重写了configure(AuthenticationManagerBuilder auth)方法,我们在该方法中定义了一个用户,该用户信息均保存在内存中。最后还有一个非常重要的地方,需要在配置类上添加@Order(1)注解,用于提升Spring Security框架的优先级,默认数字越小,优先级越大。

第五步,由于上面定义的SecurityConfigAuthServerConfig都是授权服务器提供的,因此我们还需要提供一个暴露用户信息的接口。(此处由于授权服务器和资源服务器放在一起,因此可直接定义。但是当授权服务器和资源服务器是分开搭建时,此时由资源服务器提供该接口。)

auth-server项目内新建一个controller包,并在该包内新建一个UserController类,里面的代码如下所示:

1
2
3
4
5
6
7
@RestController
public class UserController {
@GetMapping("/user")
public Principal getCurrentUser(Principal principal){
return principal;
}
}

第六步,在项目resource/static目录下新建一个login.html文件,具体内容可以下载本项目源码进行查看。

第七步,在项目的application.properties配置文件内配置项目的端口号,如下所示:

1
server.port=2019

这样我们就搭建完成了统一认证中心。

客户端搭建

接下来我们开始搭建客户端,在oauth2-sso父工程中新建一个子模块,名称为client-app1,在选择依赖的时候选择如下三个依赖:Web、Spring Cloud Security下的Cloud Security和Cloud OAuth2:

第一步,将新创建的client-app1项目配置到父工程oauth2-sso项目的pom.xml依赖文件中:

1
2
3
4
<modules>
<module>auth-server</module>
<module>client-app1</module>
</modules>

第二步,新建一个config包,并在该包内新建一个SecurityConfig类,注意它需要继承WebSecurityConfigurerAdapter类,里面的代码如下所示:

1
2
3
4
5
6
7
8
9
@Configuration
@EnableOAuth2Sso
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().anyRequest().authenticated()
.and().csrf().disable();
}
}

上述代码非常简单,表示client-app1项目中所有的接口都需要经过认证之后才能访问,同时还需要在该类上添加@EnableOAuth2Sso注解,表示开启单点登录功能。

第三步,新建一个controller包,并在该包内新建一个HelloController类,里面的代码如下所示:

1
2
3
4
5
6
7
8
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello(){
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
return authentication.getName()+ Arrays.toString(authentication.getAuthorities().toArray());
}
}

可以看到我们定义了一个/hello接口,该接口的作用是返回当前登录用户的姓名和角色信息。

第四步,在项目的application.properties配置文件内配置项目的端口号和OAuth2的相关信息,如下所示:

1
2
3
4
5
6
7
8
security.oauth2.client.client-secret=1234
security.oauth2.client.client-id=envythink
security.oauth2.client.user-authorization-uri=http://127.0.0.1:2019/oauth/authorize
security.oauth2.client.access-token-uri=http://127.0.0.1:2019/oauth/token
security.oauth2.resource.user-info-uri=http://127.0.0.1:2019/user

server.port=2020
server.servlet.session.cookie.name=s1

在上面的配置中,client-secret表示客户端密码;client-id表示客户端ID;user-authorization-uri表示用户授权的端点;access-token-uri表示获取令牌的端点;user-info-uri表示从资源服务器上获取信息的接口;之后就是配置项目端口号和cookie的名称,这样我们就完成了客户端1的配置。

由于客户端1和客户端2的配置完成一致,只有最后项目名称和端口以及cookie的不同,因此关于客户端2的创建工作就省略。

项目测试

接下来分别启动项目,开始进行测试。首先我们在浏览器中输入http://127.0.0.1:2020/hello链接,尝试去访问客户端1中的hello接口,此时页面会跳转到统一认证中心进行认证:

之后用户以envy/1234进行登录,此时页面会跳转到http://127.0.0.1:2020/hello链接,并显示以下信息:

此时当开发者直接去访问客户端2的hello接口时,也就是http://127.0.0.1:2021/hello链接,可以看到页面显示如下信息:

可以看到用户不需要再次登录,也能访问到客户端2中的hello接口,这样我们就实现了单点登录这一功能。

请求流程分析

为了更好的对上述内容进行分析,接下来我们将通过请求流程来进一步分析:

(1)当我们访问client-app1项目的hello接口时,由于此接口被保护,只有通过认证后才能访问,因此此时我们的请求会被拦截下来,系统会将我们重定向到client-app1项目的login接口,也就是登录页面:

(2)当我们去访问client-app1项目的login接口时,由于我们配置了@EnableOAuth2Sso注解,因此请求会被再次拦截下来,单点登录拦截器会根据我们在application.properties配置文件中的配置,自动发起请求去获取授权码:

(3)由于(2)发送的请求是请求auth-server授权服务器上的内容,因此这个请求也需要先登录才能访问,所以会再次重定向到auth-server授权服务器的登录页面,也就是我们看到的统一认证中心页面:

(4)在统一认证中心用户成功登录之后,会继续执行(2)中的请求,此时就能获取到授权码了。

(5)在获取到授权码之后,此时会重定向到client-app1项目的login页面,而实际上client-app1项目是没有登录页面的,因此这个请求依旧会被拦截,此时拦截到的地址中包含授权码。拿着授权码在OAuth2ClientAuthenticationProcessingFilter类中向auth-server发起请求请求就能得到access_token了。

(6)在获取到access_token之后,接下来就向我们配置的user-info-uri地址发送请求,来获取登录的用户信息。在拿到用户信息之后,接下来会在client-app1项目上重写执行一次Spring Security的登录流程,这样完成了接口信息的获取。

以上就是如何使用Spring Boot+OAuth2.0来实现单点登录这一功能的相关分析。

(完)